refactor progress
This commit is contained in:
251
packages/admin/src/App.tsx
Normal file
251
packages/admin/src/App.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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 {createRequireAuth} from '@fluxer/admin/src/middleware/Auth';
|
||||
import {csrfMiddleware, initializeCsrf} from '@fluxer/admin/src/middleware/Csrf';
|
||||
import {createAdminErrorHandler} from '@fluxer/admin/src/middleware/ErrorHandler';
|
||||
import {resolveAdminPublicDir} from '@fluxer/admin/src/PublicDir';
|
||||
import {createAdminRoutes} from '@fluxer/admin/src/routes/Admin';
|
||||
import {createAuthRoutes} from '@fluxer/admin/src/routes/Auth';
|
||||
import {createBansRoutes} from '@fluxer/admin/src/routes/Bans';
|
||||
import {createCodesRoutes} from '@fluxer/admin/src/routes/Codes';
|
||||
import {createDiscoveryRoutes} from '@fluxer/admin/src/routes/Discovery';
|
||||
import {createGuildsRoutes} from '@fluxer/admin/src/routes/Guilds';
|
||||
import {createMessagesRoutes} from '@fluxer/admin/src/routes/Messages';
|
||||
import {createReportsRoutes} from '@fluxer/admin/src/routes/Reports';
|
||||
import type {RouteFactory} from '@fluxer/admin/src/routes/RouteTypes';
|
||||
import {createSystemRoutes} from '@fluxer/admin/src/routes/System';
|
||||
import {createUsersRoutes} from '@fluxer/admin/src/routes/Users';
|
||||
import {createVisionarySlotsRoutes} from '@fluxer/admin/src/routes/VisionarySlots';
|
||||
import {createVoiceRoutes} from '@fluxer/admin/src/routes/Voice';
|
||||
import type {AppVariables} from '@fluxer/admin/src/types/App';
|
||||
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {KVCacheProvider} from '@fluxer/cache/src/providers/KVCacheProvider';
|
||||
import {cacheHeaders} from '@fluxer/hono/src/middleware/CacheHeaders';
|
||||
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
||||
import {normalizeEndpointOrigin, validateOutboundEndpointUrl} from '@fluxer/hono/src/security/OutboundEndpoint';
|
||||
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
||||
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {KVClient} from '@fluxer/kv_client/src/KVClient';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import {throwKVRequiredError} from '@fluxer/rate_limit/src/KVRequiredError';
|
||||
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import {serveStatic} from '@hono/node-server/serve-static';
|
||||
import {Hono} from 'hono';
|
||||
|
||||
export interface CreateAdminAppOptions {
|
||||
config: AdminConfig;
|
||||
logger: LoggerInterface;
|
||||
assetVersion?: string;
|
||||
metricsCollector?: MetricsCollector;
|
||||
tracing?: TracingOptions;
|
||||
cacheService?: ICacheService;
|
||||
kvProvider?: IKVProvider | null;
|
||||
}
|
||||
|
||||
export interface AdminAppResult {
|
||||
app: Hono<{Variables: AppVariables}>;
|
||||
shutdown: () => void;
|
||||
}
|
||||
|
||||
export function createAdminApp(options: CreateAdminAppOptions): AdminAppResult {
|
||||
const {logger, assetVersion = Date.now().toString(), metricsCollector, tracing} = options;
|
||||
const config = normalizeAdminSecurityConfig(options.config);
|
||||
|
||||
const app = new Hono<{Variables: AppVariables}>();
|
||||
const publicDir = resolveAdminPublicDir();
|
||||
|
||||
let rateLimitService: RateLimitService | null = null;
|
||||
|
||||
initializeCsrf(config.secretKeyBase, config.env === 'production');
|
||||
|
||||
app.use(
|
||||
'/static/*',
|
||||
serveStatic({
|
||||
root: publicDir,
|
||||
rewriteRequestPath: (path: string) => toRelativeStaticPath(stripLeadingBasePath(path, config.basePath)),
|
||||
onNotFound: (_path) => {
|
||||
logger.error(
|
||||
{
|
||||
publicDir,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
'Admin static asset not found (expected packages/admin/public/static/app.css to exist)',
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const kvProvider = options.kvProvider ?? createHttpKVProvider(config, logger);
|
||||
|
||||
if (!kvProvider) {
|
||||
throwKVRequiredError({
|
||||
serviceName: 'Admin panel',
|
||||
configPath: 'config.kvUrl',
|
||||
fluxerServerHint: 'kvProvider is passed in as an option',
|
||||
});
|
||||
}
|
||||
|
||||
const cacheService = options.cacheService ?? new KVCacheProvider({client: kvProvider});
|
||||
rateLimitService = new RateLimitService(cacheService);
|
||||
|
||||
applyMiddlewareStack(app, {
|
||||
requestId: {},
|
||||
tracing,
|
||||
metrics: metricsCollector
|
||||
? {
|
||||
enabled: true,
|
||||
collector: metricsCollector,
|
||||
skipPaths: ['/_health', '/robots.txt'],
|
||||
}
|
||||
: undefined,
|
||||
logger: {
|
||||
log: (data) => {
|
||||
logger.debug(
|
||||
{
|
||||
method: data.method,
|
||||
path: data.path,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
},
|
||||
skip: ['/_health', '/robots.txt'],
|
||||
},
|
||||
rateLimit: rateLimitService
|
||||
? {
|
||||
enabled: true,
|
||||
service: rateLimitService,
|
||||
maxAttempts: 100,
|
||||
windowMs: 60000,
|
||||
skipPaths: ['/_health', '/robots.txt'],
|
||||
}
|
||||
: undefined,
|
||||
customMiddleware: [cacheHeaders(), csrfMiddleware],
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
if (c.req.query('sh') === '1') {
|
||||
c.set('selfHostedOverride', true);
|
||||
}
|
||||
await next();
|
||||
if (c.get('selfHostedOverride')) {
|
||||
const location = c.res.headers.get('Location');
|
||||
if (location?.startsWith('/') && !location.includes('sh=1')) {
|
||||
const separator = location.includes('?') ? '&' : '?';
|
||||
const newHeaders = new Headers(c.res.headers);
|
||||
newHeaders.set('Location', `${location}${separator}sh=1`);
|
||||
c.res = new Response(c.res.body, {
|
||||
status: c.res.status,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.onError(createAdminErrorHandler(logger, config.env === 'development'));
|
||||
|
||||
app.get('/_health', (c) => c.json({status: 'ok'}));
|
||||
|
||||
app.get('/robots.txt', (c) => {
|
||||
return c.text('User-agent: *\nDisallow: /\n');
|
||||
});
|
||||
|
||||
const requireAuth = createRequireAuth(config, assetVersion);
|
||||
const deps = {config, assetVersion, requireAuth};
|
||||
|
||||
const routeFactories: Array<RouteFactory> = [
|
||||
createAuthRoutes,
|
||||
createSystemRoutes,
|
||||
createUsersRoutes,
|
||||
createGuildsRoutes,
|
||||
createBansRoutes,
|
||||
createReportsRoutes,
|
||||
createMessagesRoutes,
|
||||
createVoiceRoutes,
|
||||
createCodesRoutes,
|
||||
createDiscoveryRoutes,
|
||||
createVisionarySlotsRoutes,
|
||||
createAdminRoutes,
|
||||
];
|
||||
|
||||
for (const factory of routeFactories) {
|
||||
app.route('/', factory(deps));
|
||||
}
|
||||
|
||||
const shutdown = (): void => {
|
||||
logger.info('Admin app shutting down');
|
||||
};
|
||||
|
||||
return {app, shutdown};
|
||||
}
|
||||
|
||||
function createHttpKVProvider(config: AdminConfig, _logger: LoggerInterface): IKVProvider | null {
|
||||
if (!config.kvUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new KVClient({url: config.kvUrl});
|
||||
}
|
||||
|
||||
function normalizeAdminSecurityConfig(config: AdminConfig): AdminConfig {
|
||||
const isProduction = config.env === 'production';
|
||||
const apiEndpoint = validateOutboundEndpointUrl(config.apiEndpoint, {
|
||||
name: 'admin.apiEndpoint',
|
||||
allowHttp: !isProduction,
|
||||
allowLocalhost: !isProduction,
|
||||
allowPrivateIpLiterals: !isProduction,
|
||||
});
|
||||
|
||||
return {
|
||||
...config,
|
||||
apiEndpoint: normalizeEndpointOrigin(apiEndpoint),
|
||||
kvUrl: normalizeKVUrl(config.kvUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKVUrl(rawKvUrl: string): string {
|
||||
const kvUrl = new URL(rawKvUrl);
|
||||
if (kvUrl.protocol !== 'redis:' && kvUrl.protocol !== 'rediss:') {
|
||||
throw new Error('admin.kvUrl must use redis or rediss');
|
||||
}
|
||||
if (kvUrl.search || kvUrl.hash) {
|
||||
throw new Error('admin.kvUrl must not include query string or fragment');
|
||||
}
|
||||
return rawKvUrl.trim();
|
||||
}
|
||||
|
||||
function stripLeadingBasePath(path: string, basePath: string): string {
|
||||
if (!basePath) return path;
|
||||
if (path === basePath) return '';
|
||||
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
function toRelativeStaticPath(path: string): string {
|
||||
if (!path) return path;
|
||||
return path.startsWith('/') ? path.slice(1) : path;
|
||||
}
|
||||
Reference in New Issue
Block a user