fix: address security review findings, implement real cache clearing, and fix lifecycle promises
This commit is contained in:
@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
|
|||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
|
||||||
console.log("🌐 Starting web server...");
|
console.log("🌐 Starting web server...");
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ mock.module("../lib/loaders/EventLoader", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock dashboard service to prevent network/db calls during event handling
|
// Mock dashboard service to prevent network/db calls during event handling
|
||||||
|
mock.module("@shared/modules/economy/lootdrop.service", () => ({
|
||||||
|
lootdropService: { clearCaches: mock(async () => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@shared/modules/trade/trade.service", () => ({
|
||||||
|
tradeService: { clearSessions: mock(() => { }) }
|
||||||
|
}));
|
||||||
|
mock.module("@/modules/admin/item_wizard", () => ({
|
||||||
|
clearDraftSessions: mock(() => { })
|
||||||
|
}));
|
||||||
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
|
||||||
dashboardService: {
|
dashboardService: {
|
||||||
recordEvent: mock(() => Promise.resolve())
|
recordEvent: mock(() => Promise.resolve())
|
||||||
@@ -48,11 +57,12 @@ describe("AuroraClient System Events", () => {
|
|||||||
let AuroraClient: any;
|
let AuroraClient: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clear mocks and re-import client to ensure fresh listeners if possible
|
systemEvents.removeAllListeners();
|
||||||
// Note: AuroraClient is a singleton, so we mostly reset its state
|
|
||||||
const module = await import("./BotClient");
|
const module = await import("./BotClient");
|
||||||
AuroraClient = module.AuroraClient;
|
AuroraClient = module.AuroraClient;
|
||||||
AuroraClient.maintenanceMode = false;
|
AuroraClient.maintenanceMode = false;
|
||||||
|
// MUST call explicitly now
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,14 +71,11 @@ describe("AuroraClient System Events", () => {
|
|||||||
*/
|
*/
|
||||||
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
|
||||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
// Give event loop time to process
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
|
||||||
|
|
||||||
expect(AuroraClient.maintenanceMode).toBe(true);
|
expect(AuroraClient.maintenanceMode).toBe(true);
|
||||||
|
|
||||||
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
await new Promise(resolve => setTimeout(resolve, 30));
|
||||||
expect(AuroraClient.maintenanceMode).toBe(false);
|
expect(AuroraClient.maintenanceMode).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,16 +84,28 @@ describe("AuroraClient System Events", () => {
|
|||||||
* Requirement: loadCommands and deployCommands should be called
|
* Requirement: loadCommands and deployCommands should be called
|
||||||
*/
|
*/
|
||||||
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
|
||||||
// Spy on the methods that should be called
|
|
||||||
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
|
||||||
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
|
||||||
|
|
||||||
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
|
||||||
|
|
||||||
// Wait for async handlers
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(loadSpy).toHaveBeenCalled();
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
expect(deploySpy).toHaveBeenCalled();
|
expect(deploySpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case: Cache Clearance
|
||||||
|
* Requirement: Service clear methods should be triggered
|
||||||
|
*/
|
||||||
|
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
|
||||||
|
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(lootdropService.clearCaches).toHaveBeenCalled();
|
||||||
|
expect(tradeService.clearSessions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ export class Client extends DiscordClient {
|
|||||||
this.commands = new Collection<string, Command>();
|
this.commands = new Collection<string, Command>();
|
||||||
this.commandLoader = new CommandLoader(this);
|
this.commandLoader = new CommandLoader(this);
|
||||||
this.eventLoader = new EventLoader(this);
|
this.eventLoader = new EventLoader(this);
|
||||||
this.setupSystemEvents();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupSystemEvents() {
|
public async setupSystemEvents() {
|
||||||
import("@shared/lib/events").then(({ systemEvents, EVENTS }) => {
|
const { systemEvents, EVENTS } = await import("@shared/lib/events");
|
||||||
|
|
||||||
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
|
||||||
console.log("🔄 System Action: Reloading commands...");
|
console.log("🔄 System Action: Reloading commands...");
|
||||||
|
try {
|
||||||
await this.loadCommands(true);
|
await this.loadCommands(true);
|
||||||
await this.deployCommands();
|
await this.deployCommands();
|
||||||
|
|
||||||
@@ -34,17 +35,36 @@ export class Client extends DiscordClient {
|
|||||||
message: "Bot: Commands reloaded and redeployed",
|
message: "Bot: Commands reloaded and redeployed",
|
||||||
icon: "✅"
|
icon: "✅"
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reload commands:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
|
||||||
console.log("🧹 System Action: Clearing caches...");
|
console.log("<EFBFBD> System Action: Clearing all internal caches...");
|
||||||
// In a real app, we'd loop through services and clear their internal maps
|
|
||||||
|
try {
|
||||||
|
// 1. Lootdrop Service
|
||||||
|
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
|
||||||
|
await lootdropService.clearCaches();
|
||||||
|
|
||||||
|
// 2. Trade Service
|
||||||
|
const { tradeService } = await import("@shared/modules/trade/trade.service");
|
||||||
|
tradeService.clearSessions();
|
||||||
|
|
||||||
|
// 3. Item Wizard
|
||||||
|
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
|
||||||
|
clearDraftSessions();
|
||||||
|
|
||||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
await dashboardService.recordEvent({
|
await dashboardService.recordEvent({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "Bot: Internal caches cleared",
|
message: "Bot: All internal caches and sessions cleared",
|
||||||
icon: "🧼"
|
icon: "🧼"
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear caches:", error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
|
||||||
@@ -52,7 +72,6 @@ export class Client extends DiscordClient {
|
|||||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
this.maintenanceMode = enabled;
|
this.maintenanceMode = enabled;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCommands(reload: boolean = false) {
|
async loadCommands(reload: boolean = false) {
|
||||||
|
|||||||
@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearDraftSessions = () => {
|
||||||
|
draftSession.clear();
|
||||||
|
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const envSchema = z.object({
|
|||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
HOST: z.string().default("127.0.0.1"),
|
HOST: z.string().default("127.0.0.1"),
|
||||||
|
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedEnv = envSchema.safeParse(process.env);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export const ClientStatsSchema = z.object({
|
|||||||
|
|
||||||
export type ClientStats = z.infer<typeof ClientStatsSchema>;
|
export type ClientStats = z.infer<typeof ClientStatsSchema>;
|
||||||
|
|
||||||
|
// Action Schemas
|
||||||
|
export const MaintenanceModeSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// WebSocket Message Schemas
|
// WebSocket Message Schemas
|
||||||
export const WsMessageSchema = z.discriminatedUnion("type", [
|
export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||||
z.object({ type: z.literal("PING") }),
|
z.object({ type: z.literal("PING") }),
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ class LootdropService {
|
|||||||
return { success: false, error: "An error occurred while processing the reward." };
|
return { success: false, error: "An error occurred while processing the reward." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public async clearCaches() {
|
||||||
|
this.channelActivity.clear();
|
||||||
|
this.channelCooldowns.clear();
|
||||||
|
console.log("[LootdropService] Caches cleared via administrative action.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lootdropService = new LootdropService();
|
export const lootdropService = new LootdropService();
|
||||||
|
|||||||
@@ -196,5 +196,10 @@ export const tradeService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSessions: () => {
|
||||||
|
sessions.clear();
|
||||||
|
console.log("[TradeService] All active trade sessions cleared.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ interface ControlPanelProps {
|
|||||||
maintenanceMode: boolean;
|
maintenanceMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
AURORA_ENV?: {
|
||||||
|
ADMIN_TOKEN: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ControlPanel component provides quick administrative actions for the bot.
|
* ControlPanel component provides quick administrative actions for the bot.
|
||||||
* Integrated with the premium glassmorphic theme.
|
* Integrated with the premium glassmorphic theme.
|
||||||
@@ -20,12 +28,16 @@ export function ControlPanel({ maintenanceMode }: ControlPanelProps) {
|
|||||||
/**
|
/**
|
||||||
* Handles triggering an administrative action via the API
|
* Handles triggering an administrative action via the API
|
||||||
*/
|
*/
|
||||||
const handleAction = async (action: string, payload?: any) => {
|
const handleAction = async (action: string, payload?: Record<string, unknown>) => {
|
||||||
setLoading(action);
|
setLoading(action);
|
||||||
try {
|
try {
|
||||||
|
const token = window.AURORA_ENV?.ADMIN_TOKEN;
|
||||||
const response = await fetch(`/api/actions/${action}`, {
|
const response = await fetch(`/api/actions/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`
|
||||||
|
},
|
||||||
body: payload ? JSON.stringify(payload) : undefined,
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Action ${action} failed`);
|
if (!response.ok) throw new Error(`Action ${action} failed`);
|
||||||
|
|||||||
@@ -51,18 +51,7 @@ mock.module("../../bot/lib/clientStats", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. Mock System Events
|
// 3. System Events (No mock needed, use real events)
|
||||||
mock.module("@shared/lib/events", () => ({
|
|
||||||
systemEvents: {
|
|
||||||
on: mock(() => { }),
|
|
||||||
emit: mock(() => { }),
|
|
||||||
},
|
|
||||||
EVENTS: {
|
|
||||||
DASHBOARD: {
|
|
||||||
NEW_EVENT: "dashboard:new_event",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("WebServer Security & Limits", () => {
|
describe("WebServer Security & Limits", () => {
|
||||||
const port = 3001;
|
const port = 3001;
|
||||||
@@ -110,4 +99,45 @@ describe("WebServer Security & Limits", () => {
|
|||||||
const data = (await response.json()) as { status: string };
|
const data = (await response.json()) as { status: string };
|
||||||
expect(data.status).toBe("ok");
|
expect(data.status).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Administrative Actions Authorization", () => {
|
||||||
|
test("should reject administrative actions without token", async () => {
|
||||||
|
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject administrative actions with invalid token", async () => {
|
||||||
|
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Authorization": "Bearer wrong-token" }
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept administrative actions with valid token", async () => {
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Authorization": `Bearer ${env.ADMIN_TOKEN}` }
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject maintenance mode with invalid payload", async () => {
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const data = await response.json() as { error: string };
|
||||||
|
expect(data.error).toBe("Invalid payload");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,16 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
// Administrative Actions
|
// Administrative Actions
|
||||||
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
||||||
try {
|
try {
|
||||||
|
// Security Check: Token-based authentication
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
|
||||||
|
console.warn(`⚠️ [API] Unauthorized administrative action attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { actionService } = await import("@shared/modules/admin/action.service");
|
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||||
|
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||||
|
|
||||||
if (url.pathname === "/api/actions/reload-commands") {
|
if (url.pathname === "/api/actions/reload-commands") {
|
||||||
const result = await actionService.reloadCommands();
|
const result = await actionService.reloadCommands();
|
||||||
@@ -113,8 +122,14 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/actions/maintenance-mode") {
|
if (url.pathname === "/api/actions/maintenance-mode") {
|
||||||
const body = await req.json() as { enabled: boolean; reason?: string };
|
const rawBody = await req.json();
|
||||||
const result = await actionService.toggleMaintenanceMode(body.enabled, body.reason);
|
const parsed = MaintenanceModeSchema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason);
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -141,19 +156,30 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
|
|
||||||
const fileRef = Bun.file(safePath);
|
const fileRef = Bun.file(safePath);
|
||||||
if (await fileRef.exists()) {
|
if (await fileRef.exists()) {
|
||||||
|
// If serving index.html, inject env vars for frontend
|
||||||
|
if (pathName === "/index.html") {
|
||||||
|
let html = await fileRef.text();
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
const envScript = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${env.ADMIN_TOKEN}" };</script>`;
|
||||||
|
html = html.replace("</head>", `${envScript}</head>`);
|
||||||
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
||||||
|
}
|
||||||
return new Response(fileRef);
|
return new Response(fileRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA Fallback: Serve index.html for unknown non-file routes
|
// SPA Fallback: Serve index.html for unknown non-file routes
|
||||||
// If the path looks like a file (has extension), return 404
|
|
||||||
// Otherwise serve index.html
|
|
||||||
const parts = pathName.split("/");
|
const parts = pathName.split("/");
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
if (lastPart?.includes(".")) {
|
if (lastPart?.includes(".")) {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(Bun.file(join(distDir, "index.html")));
|
const indexFile = Bun.file(join(distDir, "index.html"));
|
||||||
|
let indexHtml = await indexFile.text();
|
||||||
|
const { env: sharedEnv } = await import("@shared/lib/env");
|
||||||
|
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`;
|
||||||
|
indexHtml = indexHtml.replace("</head>", `${script}</head>`);
|
||||||
|
return new Response(indexHtml, { headers: { "Content-Type": "text/html" } });
|
||||||
},
|
},
|
||||||
|
|
||||||
websocket: {
|
websocket: {
|
||||||
|
|||||||
Reference in New Issue
Block a user