refactor progress
This commit is contained in:
99
packages/admin/src/routes/Admin.tsx
Normal file
99
packages/admin/src/routes/Admin.tsx
Normal 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;
|
||||
}
|
||||
163
packages/admin/src/routes/Auth.tsx
Normal file
163
packages/admin/src/routes/Auth.tsx
Normal 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;
|
||||
}
|
||||
237
packages/admin/src/routes/Bans.tsx
Normal file
237
packages/admin/src/routes/Bans.tsx
Normal 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;
|
||||
}
|
||||
94
packages/admin/src/routes/Codes.tsx
Normal file
94
packages/admin/src/routes/Codes.tsx
Normal 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;
|
||||
}
|
||||
164
packages/admin/src/routes/Discovery.tsx
Normal file
164
packages/admin/src/routes/Discovery.tsx
Normal 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;
|
||||
}
|
||||
504
packages/admin/src/routes/Guilds.tsx
Normal file
504
packages/admin/src/routes/Guilds.tsx
Normal 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;
|
||||
}
|
||||
434
packages/admin/src/routes/Messages.tsx
Normal file
434
packages/admin/src/routes/Messages.tsx
Normal 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;
|
||||
}
|
||||
146
packages/admin/src/routes/Reports.tsx
Normal file
146
packages/admin/src/routes/Reports.tsx
Normal 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;
|
||||
}
|
||||
43
packages/admin/src/routes/RouteContext.tsx
Normal file
43
packages/admin/src/routes/RouteContext.tsx
Normal 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 ?? [],
|
||||
};
|
||||
}
|
||||
32
packages/admin/src/routes/RouteTypes.tsx
Normal file
32
packages/admin/src/routes/RouteTypes.tsx
Normal 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}>;
|
||||
611
packages/admin/src/routes/System.tsx
Normal file
611
packages/admin/src/routes/System.tsx
Normal 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;
|
||||
}
|
||||
521
packages/admin/src/routes/Users.tsx
Normal file
521
packages/admin/src/routes/Users.tsx
Normal 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;
|
||||
}
|
||||
207
packages/admin/src/routes/VisionarySlots.tsx
Normal file
207
packages/admin/src/routes/VisionarySlots.tsx
Normal 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;
|
||||
}
|
||||
273
packages/admin/src/routes/Voice.tsx
Normal file
273
packages/admin/src/routes/Voice.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user