Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/http/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IncomingMessage, ServerResponse } from 'node:http';
import { ENV } from '../config/env';
import { sendJson, getHeader } from './router';
import { maintenanceService } from '../services/MaintenanceService';

// Augment the native type so `playerId` can travel through the request lifecycle.
declare module 'node:http' {
Expand Down Expand Up @@ -40,7 +41,8 @@ export async function apiKeyMiddleware(
// requireAdmin() inside the route handler will validate the key itself.
const isAdminRoute =
pathname.startsWith('/telemetry/') ||
pathname.startsWith('/api/v1/mystery-gift/admin');
pathname.startsWith('/api/v1/mystery-gift/admin') ||
pathname.startsWith('/api/v1/maintenance/admin');
if (isAdminRoute) {
const adminKey = getHeader(req, 'x-admin-key');
if (adminKey && adminKey === ENV.ADMIN_KEY) return true;
Expand All @@ -56,6 +58,37 @@ export async function apiKeyMiddleware(
return true;
}

/**
* Blocks player-facing HTTP routes while maintenance mode is enabled.
*
* @remarks
* Admin maintenance routes remain available so the mode can be disabled again.
* The public health check and docs are handled outside the router.
*/
export async function maintenanceModeMiddleware(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const pathname = (req.url || '/').split('?')[0];

const isAllowedDuringMaintenance =
pathname === '/api/v1/maintenance' ||
pathname.startsWith('/api/v1/maintenance/admin') ||
pathname === '/telemetry' ||
pathname.startsWith('/telemetry/');

if (isAllowedDuringMaintenance) return true;

const status = await maintenanceService.getStatus();
if (!status.enabled) return true;

sendJson(res, 503, {
error: 503,
maintenance: status,
});
return false;
}

// ─── Guards ───────────────────────────────────────────────────────────────────

/**
Expand Down
4 changes: 2 additions & 2 deletions src/http/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ export class Router {
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, x-player-id');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, x-player-id, x-admin-key');
res.setHeader('Access-Control-Max-Age', '86400');

if (req.method === 'OPTIONS') {
Expand Down
68 changes: 68 additions & 0 deletions src/http/routes/maintenance.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Router, sendJson, readBody } from '../router';
import { requireAdmin } from '../middleware';
import { maintenanceService } from '../../services/MaintenanceService';
import { z } from 'zod';

const UpdateMaintenanceSchema = z
.object({
enabled: z.boolean(),
message: z.string().trim().max(500).optional(),
endAt: z.union([z.iso.datetime(), z.null()]).optional(),
})
.superRefine((data, ctx) => {
if (data.enabled && (!data.message || data.message.trim() === '')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['message'],
message: 'A maintenance message is required when maintenance is enabled',
});
}
});

export function registerMaintenanceRoutes(router: Router): void {
router.get('/api/v1/maintenance', async (_req, res) => {
const status = await maintenanceService.getStatus();
sendJson(res, 200, status);
});

router.patch('/api/v1/maintenance/admin', async (req, res) => {
if (!requireAdmin(req, res)) return;

let body: unknown;
try {
body = await readBody(req);
} catch {
sendJson(res, 400, { error: 'Invalid request body' });
return;
}

const parsed = UpdateMaintenanceSchema.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, {
error: 'Invalid data',
details: z.treeifyError(parsed.error),
});
return;
}

const endAt =
parsed.data.endAt === null || parsed.data.endAt === undefined
? parsed.data.endAt
: new Date(parsed.data.endAt);

const status = await maintenanceService.update({
enabled: parsed.data.enabled,
message: parsed.data.message,
endAt,
});

sendJson(res, 200, status);
});

router.delete('/api/v1/maintenance/admin', async (req, res) => {
if (!requireAdmin(req, res)) return;

const status = await maintenanceService.disable();
sendJson(res, 200, status);
});
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { WebSocketServer } from 'ws';
import { connectDatabase } from './config/database';
import { ENV } from './config/env';
import { Router, sendJson } from './http/router';
import { apiKeyMiddleware } from './http/middleware';
import { apiKeyMiddleware, maintenanceModeMiddleware } from './http/middleware';
import { registerAuthRoutes } from './http/routes/auth.routes';
import { registerGtsRoutes } from './http/routes/gts.routes';
import { registerMysteryGiftRoutes } from './http/routes/mysteryGift.routes';
import { registerFriendRoutes } from './http/routes/friends.routes';
import { registerMaintenanceRoutes } from './http/routes/maintenance.routes';
import { registerTelemetryRoutes } from './http/routes/telemetry.routes';
import { openApiSpec, swaggerUiHtml } from './swagger';
import { createWsServer } from './ws/WsServer';
Expand Down Expand Up @@ -53,11 +54,15 @@ async function bootstrap(): Promise<void> {
// Middleware 2: API Key
router.use(apiKeyMiddleware);

// Middleware 3: maintenance gate for player-facing routes
router.use(maintenanceModeMiddleware);

// Register routes
registerAuthRoutes(router);
registerGtsRoutes(router);
registerMysteryGiftRoutes(router);
registerFriendRoutes(router);
registerMaintenanceRoutes(router);
registerTelemetryRoutes(router);

// ── Native THTP server ────────────────────────────────
Expand Down
27 changes: 27 additions & 0 deletions src/models/MaintenanceState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Schema, model, Document } from 'mongoose';

export interface MaintenanceStateData {
key: string;
enabled: boolean;
message: string;
endAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

export interface IMaintenanceState extends MaintenanceStateData, Document {}

const MaintenanceStateSchema = new Schema<IMaintenanceState>(
{
key: { type: String, required: true, unique: true, default: 'global' },
enabled: { type: Boolean, default: false },
message: { type: String, default: '', trim: true, maxlength: 500 },
endAt: { type: Date, default: null },
},
{ timestamps: true },
);

export const MaintenanceState = model<IMaintenanceState>(
'MaintenanceState',
MaintenanceStateSchema,
);
71 changes: 71 additions & 0 deletions src/services/MaintenanceService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { MaintenanceState } from '../models/MaintenanceState';
import { clients } from '../ws/clients';
import { send } from '../ws/types';

export interface MaintenanceStatus {
enabled: boolean;
message: string;
endAt: string | null;
}

const DEFAULT_STATUS: MaintenanceStatus = {
enabled: false,
message: '',
endAt: null,
};

export class MaintenanceService {
async getStatus(): Promise<MaintenanceStatus> {
const doc = await MaintenanceState.findOne({ key: 'global' }).lean();
if (!doc) return DEFAULT_STATUS;

return {
enabled: doc.enabled,
message: doc.message,
endAt: doc.endAt ? new Date(doc.endAt).toISOString() : null,
};
}

async update(input: {
enabled: boolean;
message?: string;
endAt?: Date | null;
}): Promise<MaintenanceStatus> {
const doc = await MaintenanceState.findOneAndUpdate(
{ key: 'global' },
{
$set: {
enabled: input.enabled,
message: input.enabled ? (input.message ?? '').trim() : '',
endAt: input.enabled ? (input.endAt ?? null) : null,
},
},
{
upsert: true,
new: true,
setDefaultsOnInsert: true,
},
).lean();

const status: MaintenanceStatus = {
enabled: doc.enabled,
message: doc.message,
endAt: doc.endAt ? new Date(doc.endAt).toISOString() : null,
};

this.broadcast(status);
return status;
}

async disable(): Promise<MaintenanceStatus> {
return this.update({ enabled: false });
}

broadcast(status: MaintenanceStatus): void {
for (const ws of clients.values()) {
send(ws, 'MAINTENANCE_STATUS', status);
}
}
}

export const maintenanceService = new MaintenanceService();
Loading