refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,99 @@
/*
* 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 {createApiKey, revokeApiKey} from '@fluxer/admin/src/api/AdminApiKeys';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {AdminApiKeysPage} from '@fluxer/admin/src/pages/AdminApiKeysPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getRequiredString, getStringArray, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createAdminRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/admin-api-keys', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const page = await AdminApiKeysPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
createdKey: undefined,
flashAfterAction: undefined,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/admin-api-keys', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/admin-api-keys`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'create') {
const name = getRequiredString(formData, 'name');
const acls = getStringArray(formData, 'acls[]');
if (!name) {
return redirectWithFlash(c, redirectUrl, {message: 'Name is required', type: 'error'});
}
const result = await createApiKey(config, session, name, acls);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: "API key created successfully. Copy the key now — it won't be shown again.",
type: 'success',
detail: result.data.key,
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to create API key', type: 'error'});
}
if (action === 'revoke') {
const keyId = getRequiredString(formData, 'key_id');
if (!keyId) {
return redirectWithFlash(c, redirectUrl, {message: 'Key ID is required', type: 'error'});
}
const result = await revokeApiKey(config, session, keyId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'API key revoked' : 'Failed to revoke API key',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}

View File

@@ -0,0 +1,163 @@
/*
* 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 * as usersApi from '@fluxer/admin/src/api/Users';
import {getValidSession, redirectToAuthorize} from '@fluxer/admin/src/middleware/Auth';
import {LoginPage} from '@fluxer/admin/src/pages/LoginPage';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {createSession} from '@fluxer/admin/src/Session';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {base64EncodeString} from '@fluxer/oauth2/src/OAuth2';
import {Hono} from 'hono';
import {deleteCookie, getCookie, setCookie} from 'hono/cookie';
export function createAuthRoutes({config}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/login', async (c) => {
const error = c.req.query('error');
let errorMsg: string | undefined;
if (error === 'oauth_failed') {
errorMsg = 'Authentication failed. Please try again.';
} else if (error === 'missing_admin_acl') {
errorMsg = 'Access denied: missing admin:authenticate permission. Ask an administrator to grant access.';
} else if (error) {
errorMsg = 'Login error. Please try again.';
}
const session = await getValidSession(c, config);
if (session) {
return c.redirect(`${config.basePath}/dashboard`);
}
return c.html(<LoginPage config={config} errorMessage={errorMsg} />);
});
router.get('/auth/start', (c) => {
return redirectToAuthorize(c, config);
});
router.get('/oauth2_callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = getCookie(c, 'oauth_state');
deleteCookie(c, 'oauth_state', {path: '/'});
if (!code || !state || state !== storedState) {
return c.redirect(`${config.basePath}/login?error=oauth_failed`);
}
try {
const tokenResponse = await fetch(`${config.apiEndpoint}/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.oauthRedirectUri,
client_id: config.oauthClientId,
client_secret: config.oauthClientSecret,
}),
});
if (!tokenResponse.ok) {
return c.redirect(`${config.basePath}/login?error=oauth_failed`);
}
const tokenData = (await tokenResponse.json()) as {access_token: string; token_type: string};
const accessToken = tokenData.access_token;
const userResponse = await fetch(`${config.apiEndpoint}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!userResponse.ok) {
return c.redirect(`${config.basePath}/login?error=oauth_failed`);
}
const userData = (await userResponse.json()) as {id: string};
const userId = userData.id;
const adminResult = await usersApi.getCurrentAdmin(config, {
userId,
accessToken,
createdAt: Math.floor(Date.now() / 1000),
});
if (!adminResult.ok || !adminResult.data) {
return c.redirect(`${config.basePath}/login?error=missing_admin_acl`);
}
const sessionData = createSession(userId, accessToken, config.secretKeyBase);
setCookie(c, 'session', sessionData, {
httpOnly: true,
secure: config.env === 'production',
sameSite: 'Lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
return c.redirect(`${config.basePath}/dashboard`);
} catch {
return c.redirect(`${config.basePath}/login?error=oauth_failed`);
}
});
router.post('/logout', async (c) => {
const session = await getValidSession(c, config);
if (session) {
try {
const basic = `Basic ${base64EncodeString(`${config.oauthClientId}:${config.oauthClientSecret}`)}`;
await fetch(`${config.apiEndpoint}/oauth2/revoke`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: basic,
},
body: new URLSearchParams({
token: session.accessToken,
token_type_hint: 'access_token',
}),
});
} catch {}
}
deleteCookie(c, 'session', {path: '/'});
return c.redirect(`${config.basePath}/login`);
});
router.get('/logout', async (c) => {
const session = await getValidSession(c, config);
if (session) {
return c.redirect(`${config.basePath}/dashboard`);
}
return c.redirect(`${config.basePath}/login`);
});
return router;
}

View File

@@ -0,0 +1,237 @@
/*
* 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 * as bansApi from '@fluxer/admin/src/api/Bans';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {BanManagementPage, type BanType, getBanConfig} from '@fluxer/admin/src/pages/BanManagementPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppContext, AppVariables, Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {getOptionalString, getRequiredString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
async function handleBanAction(
c: AppContext,
config: Config,
banType: BanType,
action: string | undefined,
value: string,
session: Session,
auditLogReason: string | undefined,
): Promise<Response> {
const banConfig = getBanConfig(banType);
if (action === 'ban') {
const result =
banType === 'ip'
? await bansApi.banIp(config, session, value, auditLogReason)
: banType === 'email'
? await bansApi.banEmail(config, session, value, auditLogReason)
: await bansApi.banPhone(config, session, value, auditLogReason);
if (result.ok) {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `${banConfig.entityName} ${value} banned successfully`,
type: 'success',
});
} else {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `Failed to ban ${banConfig.entityName} ${value}`,
type: 'error',
});
}
}
if (action === 'unban') {
const result =
banType === 'ip'
? await bansApi.unbanIp(config, session, value, auditLogReason)
: banType === 'email'
? await bansApi.unbanEmail(config, session, value, auditLogReason)
: await bansApi.unbanPhone(config, session, value, auditLogReason);
if (result.ok) {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `${banConfig.entityName} ${value} unbanned successfully`,
type: 'success',
});
} else {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `Failed to unban ${banConfig.entityName} ${value}`,
type: 'error',
});
}
}
if (action === 'check') {
const result =
banType === 'ip'
? await bansApi.checkIpBan(config, session, value)
: banType === 'email'
? await bansApi.checkEmailBan(config, session, value)
: await bansApi.checkPhoneBan(config, session, value);
if (result.ok) {
if (result.data.banned) {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `${banConfig.entityName} ${value} is banned`,
type: 'info',
});
} else {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: `${banConfig.entityName} ${value} is NOT banned`,
type: 'info',
});
}
} else {
return redirectWithFlash(c, `${config.basePath}${banConfig.route}`, {
message: 'Error checking ban status',
type: 'error',
});
}
}
return c.redirect(`${config.basePath}${banConfig.route}`);
}
export function createBansRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/ip-bans', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const listResult = await bansApi.listIpBans(config, session, 200);
return c.html(
<BanManagementPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
banType="ip"
listResult={
listResult.ok
? {
ok: true,
bans: listResult.data.bans.map((b) => ({value: b.ip, reverseDns: b.reverse_dns})),
}
: {ok: false, errorMessage: getErrorMessage(listResult.error)}
}
csrfToken={csrfToken}
/>,
);
});
router.post('/ip-bans', requireAuth, async (c) => {
const session = c.get('session')!;
const action = c.req.query('action');
const formData = (await c.req.parseBody()) as ParsedBody;
const ip = getRequiredString(formData, 'ip');
const auditLogReason = getOptionalString(formData, 'audit_log_reason');
if (!ip) {
return c.redirect(`${config.basePath}/ip-bans`);
}
return handleBanAction(c, config, 'ip', action, ip, session, auditLogReason);
});
router.get('/email-bans', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const listResult = await bansApi.listEmailBans(config, session, 200);
return c.html(
<BanManagementPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
banType="email"
listResult={
listResult.ok
? {ok: true, bans: listResult.data.bans.map((value) => ({value}))}
: {ok: false, errorMessage: getErrorMessage(listResult.error)}
}
csrfToken={csrfToken}
/>,
);
});
router.post('/email-bans', requireAuth, async (c) => {
const session = c.get('session')!;
const action = c.req.query('action');
const formData = (await c.req.parseBody()) as ParsedBody;
const email = getRequiredString(formData, 'email');
const auditLogReason = getOptionalString(formData, 'audit_log_reason');
if (!email) {
return c.redirect(`${config.basePath}/email-bans`);
}
return handleBanAction(c, config, 'email', action, email, session, auditLogReason);
});
router.get('/phone-bans', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const listResult = await bansApi.listPhoneBans(config, session, 200);
return c.html(
<BanManagementPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
banType="phone"
listResult={
listResult.ok
? {ok: true, bans: listResult.data.bans.map((value) => ({value}))}
: {ok: false, errorMessage: getErrorMessage(listResult.error)}
}
csrfToken={csrfToken}
/>,
);
});
router.post('/phone-bans', requireAuth, async (c) => {
const session = c.get('session')!;
const action = c.req.query('action');
const formData = (await c.req.parseBody()) as ParsedBody;
const phone = getRequiredString(formData, 'phone');
const auditLogReason = getOptionalString(formData, 'audit_log_reason');
if (!phone) {
return c.redirect(`${config.basePath}/phone-bans`);
}
return handleBanAction(c, config, 'phone', action, phone, session, auditLogReason);
});
return router;
}

View File

@@ -0,0 +1,94 @@
/*
* 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 {generateGiftCodes} from '@fluxer/admin/src/api/Codes';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {GiftCodesPage} from '@fluxer/admin/src/pages/GiftCodesPage';
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 {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createCodesRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/gift-codes', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Gift codes are not available on self-hosted instances',
type: 'error',
});
}
const pageConfig = getPageConfig(c, config);
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const codesParam = c.req.query('codes');
const generatedCodes = codesParam ? codesParam.split(',') : undefined;
return c.html(
<GiftCodesPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
adminAcls={adminAcls}
csrfToken={csrfToken}
generatedCodes={generatedCodes}
/>,
);
});
router.post('/gift-codes', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Gift codes are not available on self-hosted instances',
type: 'error',
});
}
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/gift-codes`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const countStr = getOptionalString(formData, 'count');
const count = countStr ? parseInt(countStr, 10) || 1 : 1;
const productType = getOptionalString(formData, 'product_type') || 'premium_monthly';
const result = await generateGiftCodes(config, session, count, productType);
if (result.ok) {
return redirectWithFlash(c, `${redirectUrl}?codes=${encodeURIComponent(result.data.join(','))}`, {
message: `Generated ${result.data.length} gift code(s)`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to generate gift codes', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}

View File

@@ -0,0 +1,164 @@
/*
* 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 {
approveDiscoveryApplication,
listDiscoveryApplications,
rejectDiscoveryApplication,
removeFromDiscovery,
} from '@fluxer/admin/src/api/Discovery';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {DiscoveryPage} from '@fluxer/admin/src/pages/DiscoveryPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, getRequiredString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createDiscoveryRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/discovery', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const status = c.req.query('status') ?? 'pending';
const validStatuses = ['pending', 'approved', 'rejected', 'removed'];
const currentStatus = validStatuses.includes(status) ? status : 'pending';
const result = await listDiscoveryApplications(config, session, currentStatus);
if (!result.ok) {
const errorMessage =
result.error && 'message' in result.error ? result.error.message : 'Failed to load discovery applications';
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: errorMessage,
type: 'error',
});
}
return c.html(
<DiscoveryPage
config={config}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
adminAcls={adminAcls}
csrfToken={csrfToken}
applications={result.data}
currentStatus={currentStatus}
/>,
);
});
router.post('/discovery/approve', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/discovery?status=pending`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const guildId = getRequiredString(formData, 'guild_id');
if (!guildId) {
return redirectWithFlash(c, redirectUrl, {message: 'Guild ID is required', type: 'error'});
}
const reason = getOptionalString(formData, 'reason');
const result = await approveDiscoveryApplication(config, session, guildId, reason);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully approved guild ${guildId} for discovery`,
type: 'success',
});
}
const errorMessage =
result.error && 'message' in result.error ? result.error.message : 'Failed to approve application';
return redirectWithFlash(c, redirectUrl, {message: errorMessage, type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.post('/discovery/reject', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/discovery?status=pending`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const guildId = getRequiredString(formData, 'guild_id');
if (!guildId) {
return redirectWithFlash(c, redirectUrl, {message: 'Guild ID is required', type: 'error'});
}
const reason = getRequiredString(formData, 'reason');
if (!reason) {
return redirectWithFlash(c, redirectUrl, {message: 'A reason is required for rejection', type: 'error'});
}
const result = await rejectDiscoveryApplication(config, session, guildId, reason);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully rejected guild ${guildId}`,
type: 'success',
});
}
const errorMessage =
result.error && 'message' in result.error ? result.error.message : 'Failed to reject application';
return redirectWithFlash(c, redirectUrl, {message: errorMessage, type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.post('/discovery/remove', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/discovery?status=approved`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const guildId = getRequiredString(formData, 'guild_id');
if (!guildId) {
return redirectWithFlash(c, redirectUrl, {message: 'Guild ID is required', type: 'error'});
}
const reason = getRequiredString(formData, 'reason');
if (!reason) {
return redirectWithFlash(c, redirectUrl, {message: 'A reason is required for removal', type: 'error'});
}
const result = await removeFromDiscovery(config, session, guildId, reason);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully removed guild ${guildId} from discovery`,
type: 'success',
});
}
const errorMessage =
result.error && 'message' in result.error ? result.error.message : 'Failed to remove from discovery';
return redirectWithFlash(c, redirectUrl, {message: errorMessage, type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}

View File

@@ -0,0 +1,504 @@
/*
* 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 {triggerGuildArchive} from '@fluxer/admin/src/api/Archives';
import {purgeAssets} from '@fluxer/admin/src/api/Assets';
import {
banGuildMember,
clearGuildFields,
deleteGuild,
forceAddUserToGuild,
kickGuildMember,
lookupGuild,
reloadGuild,
shutdownGuild,
transferGuildOwnership,
updateGuildFeatures,
updateGuildName,
updateGuildSettings,
updateGuildVanity,
} from '@fluxer/admin/src/api/Guilds';
import {refreshSearchIndexWithGuild} from '@fluxer/admin/src/api/Search';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {GuildDetailPage} from '@fluxer/admin/src/pages/GuildDetailPage';
import {GuildsPage} from '@fluxer/admin/src/pages/GuildsPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, getStringArray, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createGuildsRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/guilds', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const searchQuery = c.req.query('q');
const page = parseInt(c.req.query('page') ?? '0', 10);
const pageResult = await GuildsPage({
config: pageConfig,
session,
currentAdmin,
flash,
searchQuery,
page,
assetVersion,
csrfToken,
});
return c.html(pageResult ?? '');
});
router.get('/guilds/:guildId', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const guildId = c.req.param('guildId');
const tab = c.req.query('tab');
const page = c.req.query('page');
const pageResult = await GuildDetailPage({
config: pageConfig,
session,
currentAdmin,
flash,
guildId,
tab,
page,
assetVersion,
csrfToken,
});
return c.html(pageResult ?? '');
});
router.post('/guilds/:guildId', requireAuth, async (c) => {
const session = c.get('session')!;
const guildId = c.req.param('guildId');
const action = c.req.query('action');
const tab = c.req.query('tab') ?? 'overview';
const redirectUrl = `${config.basePath}/guilds/${guildId}?tab=${tab}`;
const formData = (await c.req.parseBody()) as ParsedBody;
switch (action) {
case 'clear_fields': {
const fields = getStringArray(formData, 'fields[]');
const result = await clearGuildFields(config, session, guildId, fields);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild fields cleared successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to clear guild fields',
type: 'error',
});
}
}
case 'update_features': {
const guildResult = await lookupGuild(config, session, guildId);
if (!guildResult.ok || !guildResult.data) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild not found',
type: 'error',
});
}
const currentGuild = guildResult.data;
const submittedFeatures = getStringArray(formData, 'features[]');
const customFeaturesInput = getOptionalString(formData, 'custom_features') || '';
const customFeatures = customFeaturesInput
.split(',')
.map((f) => f.trim())
.filter((f) => f !== '');
submittedFeatures.push(...customFeatures);
let finalFeatures = submittedFeatures;
if (
submittedFeatures.includes('UNAVAILABLE_FOR_EVERYONE') &&
submittedFeatures.includes('UNAVAILABLE_FOR_EVERYONE_BUT_STAFF')
) {
finalFeatures = submittedFeatures.filter((f) => f !== 'UNAVAILABLE_FOR_EVERYONE_BUT_STAFF');
}
const addFeatures = finalFeatures.filter((f) => !currentGuild.features.includes(f));
const removeFeatures = currentGuild.features.filter((f) => !finalFeatures.includes(f));
const result = await updateGuildFeatures(config, session, guildId, addFeatures, removeFeatures);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild features updated successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to update guild features',
type: 'error',
});
}
}
case 'update_disabled_operations': {
const checkedOps = getStringArray(formData, 'disabled_operations[]');
const disabledOpsValue = checkedOps.reduce((acc, opStr) => {
const val = parseInt(opStr, 10);
return Number.isNaN(val) ? acc : acc | val;
}, 0);
const result = await updateGuildSettings(config, session, guildId, {
disabled_operations: disabledOpsValue,
});
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Disabled operations updated successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to update disabled operations',
type: 'error',
});
}
}
case 'update_name': {
const name = getOptionalString(formData, 'name') || '';
const result = await updateGuildName(config, session, guildId, name);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild name updated successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to update guild name',
type: 'error',
});
}
}
case 'update_vanity': {
const vanityCode = getOptionalString(formData, 'vanity_url_code') || '';
const vanity = vanityCode === '' ? undefined : vanityCode;
const result = await updateGuildVanity(config, session, guildId, vanity);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Vanity URL updated successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to update vanity URL',
type: 'error',
});
}
}
case 'transfer_ownership': {
const newOwnerId = getOptionalString(formData, 'new_owner_id') || '';
const result = await transferGuildOwnership(config, session, guildId, newOwnerId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild ownership transferred successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to transfer guild ownership',
type: 'error',
});
}
}
case 'reload': {
const result = await reloadGuild(config, session, guildId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild reloaded successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to reload guild',
type: 'error',
});
}
}
case 'shutdown': {
const result = await shutdownGuild(config, session, guildId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild shutdown successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to shutdown guild',
type: 'error',
});
}
}
case 'delete_guild': {
const result = await deleteGuild(config, session, guildId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild deleted successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to delete guild',
type: 'error',
});
}
}
case 'update_settings': {
const verificationLevelStr = getOptionalString(formData, 'verification_level');
const mfaLevelStr = getOptionalString(formData, 'mfa_level');
const nsfwLevelStr = getOptionalString(formData, 'nsfw_level');
const explicitContentFilterStr = getOptionalString(formData, 'explicit_content_filter');
const defaultMessageNotificationsStr = getOptionalString(formData, 'default_message_notifications');
const verificationLevel = verificationLevelStr ? parseInt(verificationLevelStr, 10) : undefined;
const mfaLevel = mfaLevelStr ? parseInt(mfaLevelStr, 10) : undefined;
const nsfwLevel = nsfwLevelStr ? parseInt(nsfwLevelStr, 10) : undefined;
const explicitContentFilter = explicitContentFilterStr ? parseInt(explicitContentFilterStr, 10) : undefined;
const defaultMessageNotifications = defaultMessageNotificationsStr
? parseInt(defaultMessageNotificationsStr, 10)
: undefined;
const result = await updateGuildSettings(config, session, guildId, {
verification_level: verificationLevel,
mfa_level: mfaLevel,
nsfw_level: nsfwLevel,
explicit_content_filter: explicitContentFilter,
default_message_notifications: defaultMessageNotifications,
});
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Guild settings updated successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to update guild settings',
type: 'error',
});
}
}
case 'force_add_user': {
const userId = getOptionalString(formData, 'user_id') || '';
const result = await forceAddUserToGuild(config, session, userId, guildId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'User added to guild successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to add user to guild',
type: 'error',
});
}
}
case 'ban_member': {
const userId = (getOptionalString(formData, 'user_id') || '').trim();
if (userId === '') {
return redirectWithFlash(c, redirectUrl, {
message: 'Member ID is required.',
type: 'error',
});
}
const result = await banGuildMember(config, session, guildId, userId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Member banned successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to ban member',
type: 'error',
});
}
}
case 'kick_member': {
const userId = (getOptionalString(formData, 'user_id') || '').trim();
if (userId === '') {
return redirectWithFlash(c, redirectUrl, {
message: 'Member ID is required.',
type: 'error',
});
}
const result = await kickGuildMember(config, session, guildId, userId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Member kicked successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to kick member',
type: 'error',
});
}
}
case 'refresh_search_index': {
const indexType = getOptionalString(formData, 'index_type') || '';
const result = await refreshSearchIndexWithGuild(config, session, indexType, guildId);
if (result.ok) {
return redirectWithFlash(c, `${config.basePath}/search-index?job_id=${result.data.job_id}`, {
message: 'Search index refresh started successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to start search index refresh',
type: 'error',
});
}
}
case 'delete_emoji': {
const emojiId = (getOptionalString(formData, 'emoji_id') || '').trim();
if (emojiId === '') {
return redirectWithFlash(c, redirectUrl, {
message: 'Emoji ID is required.',
type: 'error',
});
}
const result = await purgeAssets(config, session, [emojiId]);
if (result.ok) {
const error = result.data.errors.find((err) => err.id === emojiId);
if (error) {
return redirectWithFlash(c, redirectUrl, {
message: `Emoji deletion failed: ${error.error}`,
type: 'error',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Emoji deleted successfully.',
type: 'success',
});
}
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Emoji deletion failed.',
type: 'error',
});
}
}
case 'delete_sticker': {
const stickerId = (getOptionalString(formData, 'sticker_id') || '').trim();
if (stickerId === '') {
return redirectWithFlash(c, redirectUrl, {
message: 'Sticker ID is required.',
type: 'error',
});
}
const result = await purgeAssets(config, session, [stickerId]);
if (result.ok) {
const error = result.data.errors.find((err) => err.id === stickerId);
if (error) {
return redirectWithFlash(c, redirectUrl, {
message: `Sticker deletion failed: ${error.error}`,
type: 'error',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Sticker deleted successfully.',
type: 'success',
});
}
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Sticker deletion failed.',
type: 'error',
});
}
}
case 'trigger_archive': {
const result = await triggerGuildArchive(config, session, guildId);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Archive triggered successfully',
type: 'success',
});
} else {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to trigger archive',
type: 'error',
});
}
}
default:
return redirectWithFlash(c, redirectUrl, {
message: 'Unknown action',
type: 'error',
});
}
});
return router;
}

View File

@@ -0,0 +1,434 @@
/*
* 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 {hasPermission} from '@fluxer/admin/src/AccessControlList';
import * as archivesApi from '@fluxer/admin/src/api/Archives';
import * as bulkApi from '@fluxer/admin/src/api/Bulk';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import * as messagesApi from '@fluxer/admin/src/api/Messages';
import * as systemDmApi from '@fluxer/admin/src/api/SystemDm';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {ArchivesPage} from '@fluxer/admin/src/pages/ArchivesPage';
import {BulkActionsPage} from '@fluxer/admin/src/pages/BulkActionsPage';
import {handleMessagesGet, MessagesPage} from '@fluxer/admin/src/pages/MessagesPage';
import {SystemDmPage} from '@fluxer/admin/src/pages/SystemDmPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, getRequiredString, getStringArray, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Hono} from 'hono';
function resolveArchiveSubjectType(
requestedType: string | undefined,
adminAcls: Array<string>,
): 'all' | 'user' | 'guild' {
const canViewAll = hasPermission(adminAcls, AdminACLs.ARCHIVE_VIEW_ALL);
const canViewGuild = hasPermission(adminAcls, AdminACLs.ARCHIVE_TRIGGER_GUILD);
const canViewUser = hasPermission(adminAcls, AdminACLs.ARCHIVE_TRIGGER_USER);
if (requestedType === 'all' && canViewAll) {
return 'all';
}
if (requestedType === 'guild' && canViewGuild) {
return 'guild';
}
if (requestedType === 'user' && canViewUser) {
return 'user';
}
if (canViewAll) {
return 'all';
}
if (canViewGuild) {
return 'guild';
}
return 'user';
}
export function createMessagesRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/messages', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const query: Record<string, string> = {};
const channelId = c.req.query('channel_id');
const messageId = c.req.query('message_id');
const attachmentId = c.req.query('attachment_id');
const filename = c.req.query('filename');
const contextLimit = c.req.query('context_limit');
if (channelId) query['channel_id'] = channelId;
if (messageId) query['message_id'] = messageId;
if (attachmentId) query['attachment_id'] = attachmentId;
if (filename) query['filename'] = filename;
if (contextLimit) query['context_limit'] = contextLimit;
const {lookupResult, prefillChannelId} = await handleMessagesGet(
config,
session,
currentAdmin,
flash,
adminAcls,
assetVersion,
query,
);
const page = await MessagesPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
adminAcls,
csrfToken,
lookupResult,
prefillChannelId,
});
return c.html(page ?? '');
});
router.post('/messages', requireAuth, async (c) => {
const session = c.get('session')!;
const action = c.req.query('action');
const formData = (await c.req.parseBody()) as ParsedBody;
if (action === 'lookup') {
const channelId = getOptionalString(formData, 'channel_id') ?? '';
const messageId = getOptionalString(formData, 'message_id') ?? '';
const contextLimitStr = getOptionalString(formData, 'context_limit');
const contextLimit = contextLimitStr ? parseInt(contextLimitStr, 10) || 50 : 50;
return c.redirect(
`${config.basePath}/messages?channel_id=${channelId}&message_id=${messageId}&context_limit=${contextLimit}`,
);
}
if (action === 'lookup-by-attachment') {
const channelId = getOptionalString(formData, 'channel_id') ?? '';
const attachmentId = getOptionalString(formData, 'attachment_id') ?? '';
const filename = getOptionalString(formData, 'filename') ?? '';
const contextLimitStr = getOptionalString(formData, 'context_limit');
const contextLimit = contextLimitStr ? parseInt(contextLimitStr, 10) || 50 : 50;
return c.redirect(
`${config.basePath}/messages?channel_id=${channelId}&attachment_id=${attachmentId}&filename=${filename}&context_limit=${contextLimit}`,
);
}
if (action === 'delete') {
const channelId = getRequiredString(formData, 'channel_id');
const messageId = getRequiredString(formData, 'message_id');
const auditLogReason = getOptionalString(formData, 'audit_log_reason') ?? '';
if (!channelId || !messageId) {
return c.json({success: false, error: 'Missing channel_id or message_id'}, 400);
}
await messagesApi.deleteMessage(config, session, channelId, messageId, auditLogReason);
return c.json({success: true});
}
return c.redirect(`${config.basePath}/messages`);
});
router.get('/system-dms', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const result = await systemDmApi.listSystemDmJobs(config, session, 20);
const jobs = result.ok ? result.data.jobs : [];
const page = await SystemDmPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
adminAcls,
jobs,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/system-dms', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/system-dms`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'send') {
const content = getRequiredString(formData, 'content');
const registrationStart = getOptionalString(formData, 'registration_start');
const registrationEnd = getOptionalString(formData, 'registration_end');
const excludedGuildIdsInput = getOptionalString(formData, 'excluded_guild_ids');
if (!content) {
return redirectWithFlash(c, redirectUrl, {message: 'Message content is required', type: 'error'});
}
const excludedGuildIds = excludedGuildIdsInput
? excludedGuildIdsInput
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== '')
: [];
const result = await systemDmApi.createSystemDmJob(
config,
session,
content,
registrationStart || undefined,
registrationEnd || undefined,
excludedGuildIds,
);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'System DM job created' : 'Failed to send system DM',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'approve') {
const jobId = getRequiredString(formData, 'job_id');
if (!jobId) {
return redirectWithFlash(c, redirectUrl, {message: 'Job ID is required', type: 'error'});
}
const result = await systemDmApi.approveSystemDmJob(config, session, jobId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'System DM job approved' : 'Failed to approve system DM job',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.get('/archives', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const requestedSubjectType = c.req.query('subject_type') ?? undefined;
const subjectId = c.req.query('subject_id');
const subjectType = subjectId
? requestedSubjectType || 'user'
: resolveArchiveSubjectType(requestedSubjectType, adminAcls);
let archives: Array<archivesApi.Archive> = [];
let error: string | undefined;
if (subjectId) {
const result = await archivesApi.listArchives(config, session, subjectType, subjectId);
if (result.ok) {
archives = result.data.archives;
} else {
error = getErrorMessage(result.error);
}
} else if (currentAdmin?.id) {
const result = await archivesApi.listArchives(config, session, subjectType, undefined, false, currentAdmin.id);
if (result.ok) {
archives = result.data.archives;
} else {
error = getErrorMessage(result.error);
}
} else {
error = 'Failed to load archives';
}
const page = await ArchivesPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
subjectType,
subjectId,
archives,
error,
});
return c.html(page ?? '');
});
router.get('/archives/download', requireAuth, async (c) => {
const session = c.get('session')!;
const subjectType = c.req.query('subject_type');
const subjectId = c.req.query('subject_id');
const archiveId = c.req.query('archive_id');
if (!subjectType || !subjectId || !archiveId) {
return c.redirect(`${config.basePath}/archives`);
}
const result = await archivesApi.getArchiveDownloadUrl(config, session, subjectType, subjectId, archiveId);
if (result.ok && result.data.downloadUrl) {
return c.redirect(result.data.downloadUrl);
}
return redirectWithFlash(c, `${config.basePath}/archives?subject_type=${subjectType}&subject_id=${subjectId}`, {
message: 'Failed to get archive download URL',
type: 'error',
});
});
router.get('/bulk-actions', requireAuth, async (c) => {
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
return c.html(
<BulkActionsPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
adminAcls={adminAcls}
csrfToken={csrfToken}
/>,
);
});
router.post('/bulk-actions', requireAuth, async (c) => {
const session = c.get('session')!;
const action = c.req.query('action');
const formData = (await c.req.parseBody()) as ParsedBody;
if (action === 'bulk-update-user-flags') {
const userIdsText = getRequiredString(formData, 'user_ids');
if (!userIdsText) {
return c.redirect(`${config.basePath}/bulk-actions`);
}
const addFlags = getStringArray(formData, 'add_flags[]');
const removeFlags = getStringArray(formData, 'remove_flags[]');
const auditLogReason = getOptionalString(formData, 'audit_log_reason') ?? '';
const userIds = userIdsText
.split('\n')
.map((id) => id.trim())
.filter(Boolean);
await bulkApi.bulkUpdateUserFlags(config, session, userIds, addFlags, removeFlags, auditLogReason);
return c.redirect(`${config.basePath}/bulk-actions`);
}
if (action === 'bulk-update-guild-features') {
const guildIdsText = getRequiredString(formData, 'guild_ids');
if (!guildIdsText) {
return c.redirect(`${config.basePath}/bulk-actions`);
}
const addFeatures = getStringArray(formData, 'add_features[]');
const removeFeatures = getStringArray(formData, 'remove_features[]');
const customAddFeatures = getOptionalString(formData, 'custom_add_features') ?? '';
const customRemoveFeatures = getOptionalString(formData, 'custom_remove_features') ?? '';
const auditLogReason = getOptionalString(formData, 'audit_log_reason') ?? '';
const guildIds = guildIdsText
.split('\n')
.map((id) => id.trim())
.filter(Boolean);
const allAddFeatures = [
...addFeatures,
...customAddFeatures
.split(',')
.map((f) => f.trim())
.filter(Boolean),
];
const allRemoveFeatures = [
...removeFeatures,
...customRemoveFeatures
.split(',')
.map((f) => f.trim())
.filter(Boolean),
];
await bulkApi.bulkUpdateGuildFeatures(
config,
session,
guildIds,
allAddFeatures,
allRemoveFeatures,
auditLogReason,
);
return c.redirect(`${config.basePath}/bulk-actions`);
}
if (action === 'bulk-add-guild-members') {
const guildId = getRequiredString(formData, 'guild_id');
const userIdsText = getRequiredString(formData, 'user_ids');
const auditLogReason = getOptionalString(formData, 'audit_log_reason') ?? '';
if (!guildId || !userIdsText) {
return c.redirect(`${config.basePath}/bulk-actions`);
}
const userIds = userIdsText
.split('\n')
.map((id) => id.trim())
.filter(Boolean);
await bulkApi.bulkAddGuildMembers(config, session, guildId, userIds, auditLogReason);
return c.redirect(`${config.basePath}/bulk-actions`);
}
if (action === 'bulk-schedule-user-deletion') {
const userIdsText = getRequiredString(formData, 'user_ids');
const reasonCodeStr = getOptionalString(formData, 'reason_code');
const daysUntilDeletionStr = getOptionalString(formData, 'days_until_deletion');
const publicReason = getOptionalString(formData, 'public_reason') ?? '';
const auditLogReason = getOptionalString(formData, 'audit_log_reason') ?? '';
if (!userIdsText) {
return c.redirect(`${config.basePath}/bulk-actions`);
}
const reasonCode = reasonCodeStr ? parseInt(reasonCodeStr, 10) : 0;
const daysUntilDeletion = daysUntilDeletionStr ? parseInt(daysUntilDeletionStr, 10) : 30;
const userIds = userIdsText
.split('\n')
.map((id) => id.trim())
.filter(Boolean);
await bulkApi.bulkScheduleUserDeletion(
config,
session,
userIds,
reasonCode,
daysUntilDeletion,
publicReason,
auditLogReason,
);
return c.redirect(`${config.basePath}/bulk-actions`);
}
return c.redirect(`${config.basePath}/bulk-actions`);
});
return router;
}

View File

@@ -0,0 +1,146 @@
/*
* 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 {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {getReportDetail, resolveReport} from '@fluxer/admin/src/api/Reports';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {ReportDetailFragment, ReportDetailPage} from '@fluxer/admin/src/pages/ReportDetailPage';
import {ReportsPage} from '@fluxer/admin/src/pages/ReportsPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createReportsRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/reports', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const query = c.req.query('q');
const page = parseInt(c.req.query('page') ?? '0', 10);
const limit = parseInt(c.req.query('limit') ?? '25', 10);
const statusParam = c.req.query('status');
const typeParam = c.req.query('type');
const categoryFilter = c.req.query('category');
const sort = c.req.query('sort');
const statusFilter = statusParam ? parseInt(statusParam, 10) : undefined;
const typeFilter = typeParam ? parseInt(typeParam, 10) : undefined;
const pageResult = await ReportsPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
query,
page,
limit,
statusFilter,
typeFilter,
categoryFilter,
sort,
csrfToken,
});
return c.html(pageResult ?? '');
});
router.get('/reports/:reportId', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const reportId = c.req.param('reportId');
const reportResult = await getReportDetail(config, session, reportId);
if (!reportResult.ok) {
return redirectWithFlash(c, `${config.basePath}/reports`, {
message: getErrorMessage(reportResult.error),
type: 'error',
});
}
return c.html(
<ReportDetailPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
report={reportResult.data}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>,
);
});
router.get('/reports/:reportId/fragment', requireAuth, async (c) => {
const session = c.get('session')!;
const pageConfig = getPageConfig(c, config);
const reportId = c.req.param('reportId');
const reportResult = await getReportDetail(config, session, reportId);
if (!reportResult.ok) {
return c.html(
<div data-report-fragment="" class="rounded-xl border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
Failed to load report: {getErrorMessage(reportResult.error)}
</div>,
);
}
return c.html(<ReportDetailFragment config={pageConfig} report={reportResult.data} />);
});
router.post('/reports/:reportId/resolve', requireAuth, async (c) => {
const session = c.get('session')!;
const reportId = c.req.param('reportId');
const redirectUrl = `${config.basePath}/reports/${reportId}`;
const isBackground = c.req.query('background') === '1';
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const publicComment = getOptionalString(formData, 'public_comment');
const auditLogReason = getOptionalString(formData, 'audit_log_reason');
const result = await resolveReport(config, session, reportId, publicComment, auditLogReason);
if (isBackground) {
if (!result.ok) {
return c.text(getErrorMessage(result.error), 400);
}
return c.body(null, 204);
}
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Report resolved' : getErrorMessage(result.error),
type: result.ok ? 'success' : 'error',
});
} catch {
if (isBackground) {
return c.text('Invalid form data', 400);
}
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}

View File

@@ -0,0 +1,43 @@
/*
* 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/>.
*/
import {getFlash} from '@fluxer/admin/src/middleware/Auth';
import {getCsrfToken} from '@fluxer/admin/src/middleware/Csrf';
import type {AppContext, Session} from '@fluxer/admin/src/types/App';
import type {Flash} from '@fluxer/hono/src/Flash';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
export interface RouteContext {
session: Session;
currentAdmin: UserAdminResponse | undefined;
flash: Flash | undefined;
csrfToken: string;
adminAcls: Array<string>;
}
export function getRouteContext(c: AppContext): RouteContext {
const currentAdmin = c.get('currentAdmin');
return {
session: c.get('session')!,
currentAdmin,
flash: getFlash(c),
csrfToken: getCsrfToken(c),
adminAcls: currentAdmin?.acls ?? [],
};
}

View File

@@ -0,0 +1,32 @@
/*
* 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/>.
*/
import type {AppContext, AppVariables} from '@fluxer/admin/src/types/App';
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
import type {Hono, Next} from 'hono';
export type RequireAuthMiddleware = (c: AppContext, next: Next) => Promise<Response | undefined>;
export interface RouteFactoryDeps {
config: AdminConfig;
assetVersion: string;
requireAuth: RequireAuthMiddleware;
}
export type RouteFactory = (deps: RouteFactoryDeps) => Hono<{Variables: AppVariables}>;

View File

@@ -0,0 +1,611 @@
/*
* 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;
}

View File

@@ -0,0 +1,521 @@
/*
* 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 {PATCHABLE_FLAGS} from '@fluxer/admin/src/AdminPackageConstants';
import * as messagesApi from '@fluxer/admin/src/api/Messages';
import * as usersApi from '@fluxer/admin/src/api/Users';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {UserDetailPage} from '@fluxer/admin/src/pages/UserDetailPage';
import {UsersPage} from '@fluxer/admin/src/pages/UsersPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {hasBigIntFlag, tryParseBigInt} from '@fluxer/admin/src/utils/Bigint';
import {getOptionalString, getRequiredString, getStringArray, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
function parseCsvEntries(
csvData: string,
): {ok: true; entries: Array<messagesApi.ShredEntry>} | {ok: false; error: string} {
const normalized = csvData.trim();
const lines = normalized.split('\n');
const entries: Array<messagesApi.ShredEntry> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const normalizedLower = trimmed.toLowerCase();
if (normalizedLower === 'channel_id,message_id') continue;
const parts = trimmed.split(',');
if (parts.length !== 2) {
return {ok: false, error: 'Each row must contain channel_id and message_id separated by a comma'};
}
const channelRaw = parts[0]?.trim();
const messageRaw = parts[1]?.trim();
if (!channelRaw || !messageRaw) {
return {ok: false, error: `Invalid row format: ${trimmed}`};
}
const channelValue = parseInt(channelRaw, 10);
const messageValue = parseInt(messageRaw, 10);
if (Number.isNaN(channelValue)) {
return {ok: false, error: `Invalid channel_id on row: ${trimmed}`};
}
if (Number.isNaN(messageValue)) {
return {ok: false, error: `Invalid message_id on row: ${trimmed}`};
}
entries.push({
channel_id: String(channelValue),
message_id: String(messageValue),
});
}
return {ok: true, entries};
}
export function createUsersRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/users', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const searchQuery = c.req.query('q');
const page = parseInt(c.req.query('page') ?? '0', 10);
return c.html(
<UsersPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
searchQuery={searchQuery}
page={page}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>,
);
});
router.get('/users/:userId', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const userId = c.req.param('userId');
const tab = c.req.query('tab');
const guildsBefore = c.req.query('guilds_before');
const guildsAfter = c.req.query('guilds_after');
const guildsLimit = c.req.query('guilds_limit');
const guildsWithCounts = c.req.query('guilds_with_counts');
const dmBefore = c.req.query('dm_before');
const dmAfter = c.req.query('dm_after');
const dmLimit = c.req.query('dm_limit');
const messageShredJobId = c.req.query('message_shred_job_id');
const deleteAllMessagesDryRun = c.req.query('delete_all_messages_dry_run');
const deleteAllMessagesChannelCount = c.req.query('delete_all_messages_channel_count');
const deleteAllMessagesMessageCount = c.req.query('delete_all_messages_message_count');
return c.html(
<UserDetailPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
userId={userId}
tab={tab}
guildsBefore={guildsBefore}
guildsAfter={guildsAfter}
guildsLimit={guildsLimit}
guildsWithCounts={guildsWithCounts}
dmBefore={dmBefore}
dmAfter={dmAfter}
dmLimit={dmLimit}
messageShredJobId={messageShredJobId}
deleteAllMessagesDryRun={deleteAllMessagesDryRun}
deleteAllMessagesChannelCount={deleteAllMessagesChannelCount}
deleteAllMessagesMessageCount={deleteAllMessagesMessageCount}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>,
);
});
router.post('/users/:userId', requireAuth, async (c) => {
const session = c.get('session')!;
const userId = c.req.param('userId');
const tab = c.req.query('tab') ?? 'overview';
const action = c.req.query('action');
const redirectUrl = `${config.basePath}/users/${userId}?tab=${tab}`;
if (!action) {
return redirectWithFlash(c, redirectUrl, {
message: 'No action specified',
type: 'error',
});
}
try {
const formData = (await c.req.parseBody()) as ParsedBody;
switch (action) {
case 'update_flags': {
const userResult = await usersApi.lookupUser(config, session, userId);
if (!userResult.ok || !userResult.data) {
return redirectWithFlash(c, redirectUrl, {
message: 'User not found',
type: 'error',
});
}
const user = userResult.data;
const submittedFlagsRaw = getStringArray(formData, 'flags[]');
const submittedFlags = submittedFlagsRaw.map((v) => tryParseBigInt(v)).filter((v): v is bigint => v !== null);
const selectedFlagSet = new Set<bigint>(submittedFlags);
const currentFlags = tryParseBigInt(user.flags) ?? 0n;
const addFlags = submittedFlags
.filter((flag) => !hasBigIntFlag(currentFlags, flag))
.map((flag) => flag.toString());
const removeFlags = PATCHABLE_FLAGS.map((f) => f.value)
.filter((flag) => hasBigIntFlag(currentFlags, flag) && !selectedFlagSet.has(flag))
.map((flag) => flag.toString());
const result = await usersApi.updateUserFlags(config, session, userId, addFlags, removeFlags);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'User flags updated successfully' : 'Failed to update user flags',
type: result.ok ? 'success' : 'error',
});
}
case 'update_suspicious_flags': {
const flagValues = getStringArray(formData, 'suspicious_flags[]').map((v) => parseInt(v, 10));
const totalFlags = flagValues.reduce((acc, flag) => acc | flag, 0);
const result = await usersApi.updateSuspiciousActivityFlags(config, session, userId, totalFlags);
return redirectWithFlash(c, redirectUrl, {
message: result.ok
? 'Suspicious activity flags updated successfully'
: 'Failed to update suspicious activity flags',
type: result.ok ? 'success' : 'error',
});
}
case 'update_acls': {
const acls = getStringArray(formData, 'acls[]');
const result = await usersApi.setUserAcls(config, session, userId, acls);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'ACLs updated successfully' : 'Failed to update ACLs',
type: result.ok ? 'success' : 'error',
});
}
case 'update_traits': {
const selectedTraits = getStringArray(formData, 'traits[]')
.map((v) => v.trim())
.filter((v) => v !== '');
const customInput = getOptionalString(formData, 'custom_traits') ?? '';
const normalizedCustom = customInput.replace(/\n/g, ',');
const customTraits = normalizedCustom
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
const submittedTraits = [...new Set([...selectedTraits, ...customTraits])];
const result = await usersApi.setUserTraits(config, session, userId, submittedTraits);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Traits updated successfully' : 'Failed to update traits',
type: result.ok ? 'success' : 'error',
});
}
case 'disable_mfa': {
const result = await usersApi.disableMfa(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'MFA disabled successfully' : 'Failed to disable MFA',
type: result.ok ? 'success' : 'error',
});
}
case 'verify_email': {
const result = await usersApi.verifyEmail(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Email verified successfully' : 'Failed to verify email',
type: result.ok ? 'success' : 'error',
});
}
case 'unlink_phone': {
const result = await usersApi.unlinkPhone(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Phone unlinked successfully' : 'Failed to unlink phone',
type: result.ok ? 'success' : 'error',
});
}
case 'terminate_sessions': {
const result = await usersApi.terminateSessions(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Sessions terminated successfully' : 'Failed to terminate sessions',
type: result.ok ? 'success' : 'error',
});
}
case 'clear_fields': {
const fields = getStringArray(formData, 'fields[]');
if (fields.length === 0) {
return redirectWithFlash(c, redirectUrl, {
message: 'No fields selected to clear',
type: 'error',
});
}
const result = await usersApi.clearUserFields(config, session, userId, fields);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'User fields cleared successfully' : 'Failed to clear user fields',
type: result.ok ? 'success' : 'error',
});
}
case 'set_bot_status': {
const status = c.req.query('status') ?? 'false';
const bot = status === 'true';
const result = await usersApi.setBotStatus(config, session, userId, bot);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Bot status updated successfully' : 'Failed to update bot status',
type: result.ok ? 'success' : 'error',
});
}
case 'set_system_status': {
const status = c.req.query('status') ?? 'false';
const system = status === 'true';
const result = await usersApi.setSystemStatus(config, session, userId, system);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'System status updated successfully' : 'Failed to update system status',
type: result.ok ? 'success' : 'error',
});
}
case 'change_username': {
const username = getRequiredString(formData, 'username');
const discriminatorStr = getOptionalString(formData, 'discriminator');
const discriminator = discriminatorStr ? parseInt(discriminatorStr, 10) : undefined;
if (!username) {
return redirectWithFlash(c, redirectUrl, {
message: 'Username cannot be empty',
type: 'error',
});
}
const result = await usersApi.changeUsername(config, session, userId, username, discriminator);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Username changed successfully' : 'Failed to change username',
type: result.ok ? 'success' : 'error',
});
}
case 'change_email': {
const email = getRequiredString(formData, 'email');
if (!email) {
return redirectWithFlash(c, redirectUrl, {
message: 'Email cannot be empty',
type: 'error',
});
}
const result = await usersApi.changeEmail(config, session, userId, email);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Email changed successfully' : 'Failed to change email',
type: result.ok ? 'success' : 'error',
});
}
case 'temp_ban': {
const durationStr = getOptionalString(formData, 'duration') ?? '24';
const duration = parseInt(durationStr, 10);
const publicReason = getOptionalString(formData, 'reason');
const privateReason = getOptionalString(formData, 'private_reason');
const result = await usersApi.tempBanUser(config, session, userId, duration, publicReason, privateReason);
const isPermanent = duration <= 0;
return redirectWithFlash(c, redirectUrl, {
message: result.ok
? isPermanent
? 'User banned permanently'
: 'User temporarily banned successfully'
: isPermanent
? 'Failed to permanently ban user'
: 'Failed to temporarily ban user',
type: result.ok ? 'success' : 'error',
});
}
case 'unban': {
const result = await usersApi.unbanUser(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'User unbanned successfully' : 'Failed to unban user',
type: result.ok ? 'success' : 'error',
});
}
case 'schedule_deletion': {
const reasonCodeStr = getOptionalString(formData, 'reason_code') ?? '0';
const reasonCode = parseInt(reasonCodeStr, 10);
const daysStr = getOptionalString(formData, 'days') ?? '30';
const days = parseInt(daysStr, 10);
const publicReason = getOptionalString(formData, 'public_reason');
const privateReason = getOptionalString(formData, 'private_reason');
const result = await usersApi.scheduleDeletion(
config,
session,
userId,
reasonCode,
publicReason,
days,
privateReason,
);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Account deletion scheduled successfully' : 'Failed to schedule account deletion',
type: result.ok ? 'success' : 'error',
});
}
case 'cancel_deletion': {
const result = await usersApi.cancelDeletion(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Account deletion cancelled successfully' : 'Failed to cancel account deletion',
type: result.ok ? 'success' : 'error',
});
}
case 'cancel_bulk_message_deletion': {
const result = await usersApi.cancelBulkMessageDeletion(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok
? 'Bulk message deletion cancelled successfully'
: 'Failed to cancel bulk message deletion',
type: result.ok ? 'success' : 'error',
});
}
case 'delete_all_messages': {
const dryRunValue = getOptionalString(formData, 'dry_run') ?? 'true';
const dryRun = !(dryRunValue.toLowerCase() === 'false' || dryRunValue === '0');
const result = await messagesApi.deleteAllUserMessages(config, session, userId, dryRun);
if (!result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to delete all user messages',
type: 'error',
});
}
if (dryRun) {
const location = `${redirectUrl}&delete_all_messages_dry_run=true&delete_all_messages_channel_count=${result.data.channel_count}&delete_all_messages_message_count=${result.data.message_count}`;
return redirectWithFlash(c, location, {
message: `Dry run found ${result.data.message_count} messages across ${result.data.channel_count} channels. Confirm to delete them permanently.`,
type: 'success',
});
} else {
const location = result.data.job_id
? `${redirectUrl}&message_shred_job_id=${result.data.job_id}`
: redirectUrl;
const message = result.data.job_id
? 'Delete job queued. Monitor progress in the status panel.'
: 'No messages found for deletion.';
return redirectWithFlash(c, location, {
message,
type: 'success',
});
}
}
case 'change_dob': {
const dateOfBirth = getRequiredString(formData, 'date_of_birth');
if (!dateOfBirth) {
return redirectWithFlash(c, redirectUrl, {
message: 'Invalid date of birth',
type: 'error',
});
}
const result = await usersApi.changeDob(config, session, userId, dateOfBirth);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Date of birth changed successfully' : 'Failed to change date of birth',
type: result.ok ? 'success' : 'error',
});
}
case 'send_password_reset': {
const result = await usersApi.sendPasswordReset(config, session, userId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Password reset email sent successfully' : 'Failed to send password reset email',
type: result.ok ? 'success' : 'error',
});
}
case 'message_shred': {
const csvData = getOptionalString(formData, 'csv_data') ?? '';
const parsedEntries = parseCsvEntries(csvData);
if (!parsedEntries.ok) {
return redirectWithFlash(c, redirectUrl, {
message: parsedEntries.error,
type: 'error',
});
}
if (parsedEntries.entries.length === 0) {
return redirectWithFlash(c, redirectUrl, {
message: 'CSV did not contain any valid channel_id,message_id pairs.',
type: 'error',
});
}
const result = await messagesApi.queueMessageShred(config, session, userId, parsedEntries.entries);
if (!result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: 'Failed to queue message shred job',
type: 'error',
});
}
const location = `${redirectUrl}&message_shred_job_id=${result.data.job_id}`;
return redirectWithFlash(c, location, {
message: 'Message shred job queued',
type: 'success',
});
}
default:
return redirectWithFlash(c, redirectUrl, {
message: `Unknown action: ${action}`,
type: 'error',
});
}
} catch (e) {
return redirectWithFlash(c, redirectUrl, {
message: `Error processing action: ${(e as Error).message}`,
type: 'error',
});
}
});
return router;
}

View File

@@ -0,0 +1,207 @@
/*
* 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 {
expandVisionarySlots,
listVisionarySlots,
reserveVisionarySlot,
shrinkVisionarySlots,
swapVisionarySlots,
} from '@fluxer/admin/src/api/VisionarySlots';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {VisionarySlotsPage} from '@fluxer/admin/src/pages/VisionarySlotsPage';
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 {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createVisionarySlotsRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/visionary-slots', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Visionary slots are not available on self-hosted instances',
type: 'error',
});
}
const pageConfig = getPageConfig(c, config);
const {session, currentAdmin, flash, adminAcls, csrfToken} = getRouteContext(c);
const result = await listVisionarySlots(config, session);
if (!result.ok) {
const errorMessage =
result.error && 'message' in result.error ? result.error.message : 'Failed to load visionary slots';
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: errorMessage,
type: 'error',
});
}
const sortedSlots = result.data.slots.sort((a, b) => a.slot_index - b.slot_index);
return c.html(
<VisionarySlotsPage
config={pageConfig}
session={session}
currentAdmin={currentAdmin}
flash={flash}
assetVersion={assetVersion}
adminAcls={adminAcls}
csrfToken={csrfToken}
slots={sortedSlots}
totalCount={result.data.total_count}
reservedCount={result.data.reserved_count}
/>,
);
});
router.post('/visionary-slots/expand', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Visionary slots are not available on self-hosted instances',
type: 'error',
});
}
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/visionary-slots`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const countStr = getOptionalString(formData, 'count');
const count = countStr ? parseInt(countStr, 10) || 1 : 1;
const result = await expandVisionarySlots(config, session, count);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully added ${count} visionary slot(s)`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to expand visionary slots', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.post('/visionary-slots/shrink', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Visionary slots are not available on self-hosted instances',
type: 'error',
});
}
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/visionary-slots`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const targetCountStr = getOptionalString(formData, 'target_count');
const targetCount = targetCountStr ? parseInt(targetCountStr, 10) || 0 : 0;
const result = await shrinkVisionarySlots(config, session, targetCount);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully shrunk visionary slots to ${targetCount}`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to shrink visionary slots', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.post('/visionary-slots/reserve', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Visionary slots are not available on self-hosted instances',
type: 'error',
});
}
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/visionary-slots`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const slotIndexStr = getOptionalString(formData, 'slot_index');
const slotIndex = slotIndexStr ? parseInt(slotIndexStr, 10) : 0;
const userIdRaw = getOptionalString(formData, 'user_id');
let userId: string | null = null;
if (userIdRaw && userIdRaw !== 'null' && userIdRaw.trim() !== '') {
userId = userIdRaw.trim();
}
const result = await reserveVisionarySlot(config, session, slotIndex, userId);
if (result.ok) {
const action = userId ? 'reserved' : 'unreserved';
return redirectWithFlash(c, redirectUrl, {
message: `Successfully ${action} slot ${slotIndex}`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to update slot reservation', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.post('/visionary-slots/swap', requireAuth, async (c) => {
if (isSelfHostedOverride(c, config)) {
return redirectWithFlash(c, `${config.basePath}/dashboard`, {
message: 'Visionary slots are not available on self-hosted instances',
type: 'error',
});
}
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/visionary-slots`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const slotIndexAStr = getOptionalString(formData, 'slot_index_a');
const slotIndexBStr = getOptionalString(formData, 'slot_index_b');
const slotIndexA = slotIndexAStr ? parseInt(slotIndexAStr, 10) : 0;
const slotIndexB = slotIndexBStr ? parseInt(slotIndexBStr, 10) : 0;
const result = await swapVisionarySlots(config, session, slotIndexA, slotIndexB);
if (result.ok) {
return redirectWithFlash(c, redirectUrl, {
message: `Successfully swapped slots ${slotIndexA} and ${slotIndexB}`,
type: 'success',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Failed to swap slots', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}

View File

@@ -0,0 +1,273 @@
/*
* 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 {
type CreateVoiceRegionParams,
type CreateVoiceServerParams,
createVoiceRegion,
createVoiceServer,
deleteVoiceRegion,
deleteVoiceServer,
type UpdateVoiceRegionParams,
type UpdateVoiceServerParams,
updateVoiceRegion,
updateVoiceServer,
} from '@fluxer/admin/src/api/Voice';
import {redirectWithFlash} from '@fluxer/admin/src/middleware/Auth';
import {VoiceRegionsPage} from '@fluxer/admin/src/pages/VoiceRegionsPage';
import {VoiceServersPage} from '@fluxer/admin/src/pages/VoiceServersPage';
import {getRouteContext} from '@fluxer/admin/src/routes/RouteContext';
import type {RouteFactoryDeps} from '@fluxer/admin/src/routes/RouteTypes';
import {getPageConfig} from '@fluxer/admin/src/SelfHostedOverride';
import type {AppVariables} from '@fluxer/admin/src/types/App';
import {getOptionalString, getRequiredString, type ParsedBody} from '@fluxer/admin/src/utils/Forms';
import {Hono} from 'hono';
export function createVoiceRoutes({config, assetVersion, requireAuth}: RouteFactoryDeps) {
const router = new Hono<{Variables: AppVariables}>();
router.get('/voice-regions', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const page = await VoiceRegionsPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/voice-regions', requireAuth, async (c) => {
const session = c.get('session')!;
const redirectUrl = `${config.basePath}/voice-regions`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'create') {
const latitudeStr = getOptionalString(formData, 'latitude') || '0';
const longitudeStr = getOptionalString(formData, 'longitude') || '0';
const requiredGuildFeaturesStr = getOptionalString(formData, 'required_guild_features') || '';
const allowedGuildIdsStr = getOptionalString(formData, 'allowed_guild_ids') || '';
const allowedUserIdsStr = getOptionalString(formData, 'allowed_user_ids') || '';
const params: CreateVoiceRegionParams = {
id: getRequiredString(formData, 'id') || '',
name: getRequiredString(formData, 'name') || '',
emoji: getOptionalString(formData, 'emoji') || '',
latitude: parseFloat(latitudeStr),
longitude: parseFloat(longitudeStr),
is_default: getOptionalString(formData, 'is_default') === 'true',
vip_only: getOptionalString(formData, 'vip_only') === 'true',
required_guild_features: requiredGuildFeaturesStr
.split(/[\n,]/)
.map((f) => f.trim())
.filter((f) => f !== ''),
allowed_guild_ids: allowedGuildIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
allowed_user_ids: allowedUserIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
};
const result = await createVoiceRegion(config, session, params);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice region created' : 'Failed to create voice region',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'update') {
const latitudeStr = getOptionalString(formData, 'latitude') || '0';
const longitudeStr = getOptionalString(formData, 'longitude') || '0';
const requiredGuildFeaturesStr = getOptionalString(formData, 'required_guild_features') || '';
const allowedGuildIdsStr = getOptionalString(formData, 'allowed_guild_ids') || '';
const allowedUserIdsStr = getOptionalString(formData, 'allowed_user_ids') || '';
const params: UpdateVoiceRegionParams = {
id: getRequiredString(formData, 'id') || '',
name: getRequiredString(formData, 'name') || '',
emoji: getOptionalString(formData, 'emoji') || '',
latitude: parseFloat(latitudeStr),
longitude: parseFloat(longitudeStr),
is_default: getOptionalString(formData, 'is_default') === 'true',
vip_only: getOptionalString(formData, 'vip_only') === 'true',
required_guild_features: requiredGuildFeaturesStr
.split(/[\n,]/)
.map((f) => f.trim())
.filter((f) => f !== ''),
allowed_guild_ids: allowedGuildIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
allowed_user_ids: allowedUserIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
};
const result = await updateVoiceRegion(config, session, params);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice region updated' : 'Failed to update voice region',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'delete') {
const id = getRequiredString(formData, 'id');
if (!id) {
return redirectWithFlash(c, redirectUrl, {message: 'Region ID is required', type: 'error'});
}
const result = await deleteVoiceRegion(config, session, id);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice region deleted' : 'Failed to delete voice region',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
router.get('/voice-servers', requireAuth, async (c) => {
const {session, currentAdmin, flash, csrfToken} = getRouteContext(c);
const pageConfig = getPageConfig(c, config);
const regionId = c.req.query('region_id');
const page = await VoiceServersPage({
config: pageConfig,
session,
currentAdmin,
flash,
assetVersion,
regionId,
csrfToken,
});
return c.html(page ?? '');
});
router.post('/voice-servers', requireAuth, async (c) => {
const session = c.get('session')!;
const regionId = c.req.query('region_id');
const redirectUrl = `${config.basePath}/voice-servers${regionId ? `?region_id=${regionId}` : ''}`;
try {
const formData = (await c.req.parseBody()) as ParsedBody;
const action = c.req.query('action');
if (action === 'create') {
const requiredGuildFeaturesStr = getOptionalString(formData, 'required_guild_features') || '';
const allowedGuildIdsStr = getOptionalString(formData, 'allowed_guild_ids') || '';
const allowedUserIdsStr = getOptionalString(formData, 'allowed_user_ids') || '';
const params: CreateVoiceServerParams = {
region_id: getRequiredString(formData, 'region_id') || '',
server_id: getRequiredString(formData, 'server_id') || '',
endpoint: getRequiredString(formData, 'endpoint') || '',
api_key: getRequiredString(formData, 'api_key') || '',
api_secret: getRequiredString(formData, 'api_secret') || '',
is_active: getOptionalString(formData, 'is_active') === 'true',
vip_only: getOptionalString(formData, 'vip_only') === 'true',
required_guild_features: requiredGuildFeaturesStr
.split(/[\n,]/)
.map((f) => f.trim())
.filter((f) => f !== ''),
allowed_guild_ids: allowedGuildIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
allowed_user_ids: allowedUserIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
};
const result = await createVoiceServer(config, session, params);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice server created' : 'Failed to create voice server',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'update') {
const requiredGuildFeaturesStr = getOptionalString(formData, 'required_guild_features') || '';
const allowedGuildIdsStr = getOptionalString(formData, 'allowed_guild_ids') || '';
const allowedUserIdsStr = getOptionalString(formData, 'allowed_user_ids') || '';
const params: UpdateVoiceServerParams = {
region_id: getRequiredString(formData, 'region_id') || '',
server_id: getRequiredString(formData, 'server_id') || '',
endpoint: getRequiredString(formData, 'endpoint') || '',
is_active: getOptionalString(formData, 'is_active') === 'true',
vip_only: getOptionalString(formData, 'vip_only') === 'true',
required_guild_features: requiredGuildFeaturesStr
.split(/[\n,]/)
.map((f) => f.trim())
.filter((f) => f !== ''),
allowed_guild_ids: allowedGuildIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
allowed_user_ids: allowedUserIdsStr
.split(/[\n,]/)
.map((id) => id.trim())
.filter((id) => id !== ''),
};
const result = await updateVoiceServer(config, session, params);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice server updated' : 'Failed to update voice server',
type: result.ok ? 'success' : 'error',
});
}
if (action === 'delete') {
const serverRegionId = getRequiredString(formData, 'region_id');
const serverId = getRequiredString(formData, 'server_id');
if (!serverRegionId || !serverId) {
return redirectWithFlash(c, redirectUrl, {message: 'Region ID and Server ID are required', type: 'error'});
}
const result = await deleteVoiceServer(config, session, serverRegionId, serverId);
return redirectWithFlash(c, redirectUrl, {
message: result.ok ? 'Voice server deleted' : 'Failed to delete voice server',
type: result.ok ? 'success' : 'error',
});
}
return redirectWithFlash(c, redirectUrl, {message: 'Unknown action', type: 'error'});
} catch {
return redirectWithFlash(c, redirectUrl, {message: 'Invalid form data', type: 'error'});
}
});
return router;
}