Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(orders): add support for Hubble ordering system #17

Merged
merged 5 commits into from
Dec 24, 2024
Merged
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
5 changes: 5 additions & 0 deletions src/helpers/security-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ISecurityGroups {
spotify: ISecuritySections;
sudosos: ISecuritySections;
serverSettings: ISecuritySections;
orders: ISecuritySections;
}

/**
Expand Down Expand Up @@ -132,6 +133,10 @@ export const securityGroups = {
base: allSecurityGroups,
privileged: [SecurityGroup.ADMIN],
},
orders: {
base: allSecuritySubscriberGroups,
privileged: baseSecurityGroups,
},
};

// Since object above cannot be type directly, we cast it here
Expand Down
27 changes: 15 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import { ArtificialBeatGenerator } from './modules/beats/artificial-beat-generat
import initBackofficeSynchronizer from './modules/backoffice/synchronizer';
import { SocketioNamespaces } from './socketio-namespaces';
import SocketConnectionManager from './modules/root/socket-connection-manager';
import { ServerSettingsStore } from './modules/server-settings';
import { FeatureFlagManager, ServerSettingsStore } from './modules/server-settings';
import registerCronJobs from './cron';
import EmitterStore from './modules/events/emitter-store';
// do not remove; used for extending existing types
import Types from './types';
import { OrderManager } from './modules/orders';

async function createApp(): Promise<void> {
// Fix for production issue where a Docker volume overwrites the contents of a folder instead of merging them
Expand All @@ -38,30 +39,31 @@ async function createApp(): Promise<void> {
await dataSource.initialize();

await ServerSettingsStore.getInstance().initialize();
const featureFlagManager = new FeatureFlagManager();

const app = await createHttp();
const httpServer = createServer(app);
const io = createWebsocket(httpServer);

const { musicEmitter, backofficeSyncEmitter } = EmitterStore.getInstance();
const emitterStore = EmitterStore.getInstance();

const handlerManager = HandlerManager.getInstance(io, musicEmitter, backofficeSyncEmitter);
const handlerManager = HandlerManager.getInstance(io, emitterStore);
await handlerManager.init();
const socketConnectionManager = new SocketConnectionManager(
handlerManager,
io,
backofficeSyncEmitter,
emitterStore.backofficeSyncEmitter,
);
await socketConnectionManager.clearSavedSocketIds();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- TODO should this be used somewhere?
const lightsControllerManager = new LightsControllerManager(
io.of(SocketioNamespaces.LIGHTS),
handlerManager,
musicEmitter,
emitterStore.musicEmitter,
);

ModeManager.getInstance().init(musicEmitter, backofficeSyncEmitter);
ArtificialBeatGenerator.getInstance().init(musicEmitter);
ModeManager.getInstance().init(emitterStore);
ArtificialBeatGenerator.getInstance().init(emitterStore.musicEmitter);

if (
process.env.SPOTIFY_ENABLE === 'true' &&
Expand All @@ -71,13 +73,14 @@ async function createApp(): Promise<void> {
) {
logger.info('Initialize Spotify...');
await SpotifyApiHandler.getInstance().init();
await SpotifyTrackHandler.getInstance().init(musicEmitter);
await SpotifyTrackHandler.getInstance().init(emitterStore.musicEmitter);
}

initBackofficeSynchronizer(io.of('/backoffice'), {
musicEmitter,
backofficeEmitter: backofficeSyncEmitter,
});
if (featureFlagManager.flagIsEnabled('Orders')) {
OrderManager.getInstance().init(emitterStore.orderEmitter);
}

initBackofficeSynchronizer(io.of('/backoffice'), emitterStore);

registerCronJobs();

Expand Down
20 changes: 8 additions & 12 deletions src/modules/backoffice/synchronizer.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import { Namespace } from 'socket.io';
import { MusicEmitter } from '../events';
import { BackofficeSyncEmitter } from '../events/backoffice-sync-emitter';
import EmitterStore from '../events/emitter-store';

interface Emitters {
musicEmitter: MusicEmitter;
backofficeEmitter: BackofficeSyncEmitter;
}

export default function initBackofficeSynchronizer(
socket: Namespace,
{ musicEmitter, backofficeEmitter }: Emitters,
) {
musicEmitter.on('beat', (event) => {
export default function initBackofficeSynchronizer(socket: Namespace, emitterStore: EmitterStore) {
emitterStore.musicEmitter.on('beat', (event) => {
socket.emit('beat', event);
});
musicEmitter.on('change_track', (event) => {
emitterStore.musicEmitter.on('change_track', (event) => {
socket.emit('change_track', event);
});
backofficeEmitter.on('*', (eventName: string, ...args: any[]) => {
emitterStore.backofficeSyncEmitter.on('*', (eventName: string, ...args: any[]) => {
socket.emit(eventName, ...args);
});
emitterStore.orderEmitter.on('orders', (event) => {
socket.emit('orders', event);
});
}
4 changes: 4 additions & 0 deletions src/modules/events/emitter-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BackofficeSyncEmitter } from './backoffice-sync-emitter';
import { MusicEmitter } from './music-emitter';
import { OrderEmitter } from './order-emitter';

export default class EmitterStore {
private static instance: EmitterStore;
Expand All @@ -8,9 +9,12 @@ export default class EmitterStore {

public readonly musicEmitter: MusicEmitter;

public readonly orderEmitter: OrderEmitter;

constructor() {
this.backofficeSyncEmitter = new BackofficeSyncEmitter();
this.musicEmitter = new MusicEmitter();
this.orderEmitter = new OrderEmitter();
}

public static getInstance() {
Expand Down
5 changes: 5 additions & 0 deletions src/modules/events/order-emitter-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Order } from '../orders/entities';

export interface ShowOrdersEvent {
orders: Order[];
}
9 changes: 9 additions & 0 deletions src/modules/events/order-emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { EventEmitter } from 'node:events';
import { Order } from '../orders/entities';
import { ShowOrdersEvent } from './order-emitter-events';

export class OrderEmitter extends EventEmitter {
showOrders(showOrdersEvent: ShowOrdersEvent): boolean {
return super.emit('orders', showOrdersEvent);
}
}
7 changes: 7 additions & 0 deletions src/modules/handlers/base-screen-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import BaseHandler from './base-handler';
import Screen from '../root/entities/screen';
import { TrackChangeEvent } from '../events/music-emitter-events';
import { SocketioNamespaces } from '../../socketio-namespaces';
import { ShowOrdersEvent } from '../events/order-emitter-events';
import { FeatureEnabled } from '../server-settings';

export default abstract class BaseScreenHandler extends BaseHandler<Screen> {
constructor(private socket: Namespace) {
Expand All @@ -12,6 +14,11 @@ export default abstract class BaseScreenHandler extends BaseHandler<Screen> {

abstract changeTrack(event: TrackChangeEvent[]): void;

@FeatureEnabled('Orders')
public showOrders(event: ShowOrdersEvent): void {
this.sendEvent('orders', event);
}

protected sendEvent(eventName: string, ...args: EventParams<any, any>) {
this.entities.forEach((e) => {
const socketId = e.getSocketId(this.socket.name as SocketioNamespaces);
Expand Down
18 changes: 8 additions & 10 deletions src/modules/modes/mode-manager.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import BaseMode from './base-mode';
import { MusicEmitter } from '../events';
import { BackofficeSyncEmitter } from '../events/backoffice-sync-emitter';
import EmitterStore from '../events/emitter-store';

export default class ModeManager {
private static instance: ModeManager;

private _musicEmitter: MusicEmitter;

private _backofficeSyncEmitter: BackofficeSyncEmitter;
private _emitterStore: EmitterStore;

private modes: Map<typeof BaseMode, BaseMode<any, any, any> | undefined> = new Map();

Expand All @@ -20,10 +19,9 @@ export default class ModeManager {
return this.instance;
}

public init(musicEmitter: MusicEmitter, backofficeEmitter: BackofficeSyncEmitter) {
public init(emitterStore: EmitterStore) {
if (this.initialized) throw new Error('ModeManager already initialized');
this._musicEmitter = musicEmitter;
this._backofficeSyncEmitter = backofficeEmitter;
this._emitterStore = emitterStore;
this.initialized = true;
}

Expand All @@ -36,7 +34,7 @@ export default class ModeManager {
if (this.modes.has(modeClass)) this.disableMode(modeClass);

this.modes.set(modeClass, mode);
this._backofficeSyncEmitter.emit(`mode_${name}_update`);
this._emitterStore.backofficeSyncEmitter.emit(`mode_${name}_update`);
}

public getMode(modeClass: typeof BaseMode<any, any, any>) {
Expand All @@ -46,16 +44,16 @@ export default class ModeManager {
public disableMode(modeClass: typeof BaseMode<any, any, any>, name?: string) {
const instance = this.modes.get(modeClass);
if (instance) instance.destroy();
if (name) this._backofficeSyncEmitter.emit(`mode_${name}_update`);
if (name) this._emitterStore.backofficeSyncEmitter.emit(`mode_${name}_update`);
return this.modes.delete(modeClass);
}

public get musicEmitter() {
return this._musicEmitter;
return this._emitterStore.musicEmitter;
}

public get backofficeSyncEmitter() {
return this._backofficeSyncEmitter;
return this._emitterStore.backofficeSyncEmitter;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/modules/orders/entities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Order } from './order';
5 changes: 5 additions & 0 deletions src/modules/orders/entities/order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default interface Order {
number: number;
startTime: Date;
timeoutSeconds: number;
}
1 change: 1 addition & 0 deletions src/modules/orders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as OrderManager } from './order-manager';
95 changes: 95 additions & 0 deletions src/modules/orders/order-controller.ts
Yoronex marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Controller, Header, Response, TsoaResponse } from '@tsoa/runtime';
import { createVerify } from 'crypto';
import { FeatureEnabled, ServerSettingsStore } from '../server-settings';
import { Body, Delete, Get, Post, Res, Route, Security, Tags } from 'tsoa';
import { SecurityNames } from '../../helpers/security';
import { securityGroups } from '../../helpers/security-groups';
import OrderManager from './order-manager';
import { OrderSettings } from './order-settings';

interface OrderRequest {
orderNumber: number;
timeoutSeconds?: number;
}

@Tags('Orders')
@Route('/orders')
@FeatureEnabled('Orders')
export class OrderController extends Controller {
private webhookPublicKey: string;

private webhookKeyLastUpdate = new Date();

@Security(SecurityNames.LOCAL, securityGroups.orders.base)
@Get('')
@Response<string>(409, 'Endpoint is disabled in the server settings')
public async getAllOrders() {
return OrderManager.getInstance().getOrders();
}

@Security(SecurityNames.LOCAL, securityGroups.orders.privileged)
@Post('')
@Response<string>(409, 'Endpoint is disabled in the server settings')
public async addOrder(@Body() orderRequest: OrderRequest) {
const manager = OrderManager.getInstance();
await manager.addOrder(orderRequest.orderNumber, orderRequest.timeoutSeconds);
return manager.getOrders();
}

@Post('webhook')
@FeatureEnabled('Orders.WebhookPublicKeyURL')
@Response<string>(409, 'Endpoint is disabled in the server settings')
public async addOrderWebhook(
@Body() orderRequest: OrderRequest,
@Header('X-Signature') signature: string,
@Res() invalidSignatureResponse: TsoaResponse<400, { message: string }>,
) {
const settingsStore = ServerSettingsStore.getInstance();
const expiryTimeSeconds = settingsStore.getSetting(
'Orders.DefaultTimeoutSeconds',
) as OrderSettings['Orders.DefaultTimeoutSeconds'];

if (
!this.webhookPublicKey ||
new Date().getTime() - this.webhookKeyLastUpdate.getTime() > 1000 * expiryTimeSeconds
) {
const webhookKeyUrl = settingsStore.getSetting(
'Orders.WebhookPublicKeyURL',
) as OrderSettings['Orders.WebhookPublicKeyURL'];

const response = await fetch(webhookKeyUrl);
if (!response.ok) {
throw new Error(`Failed to fetch public key: ${response.statusText}`);
}
this.webhookPublicKey = await response.text();
this.webhookKeyLastUpdate = new Date();
}

// Create a verifier
const verifier = createVerify('RSA-SHA256');
verifier.update(JSON.stringify(orderRequest));
verifier.end();

// Decode the Base64 signature
const decodedSignature = Buffer.from(signature, 'base64');

// Verify the signature
const isValid = verifier.verify(this.webhookPublicKey, decodedSignature);
if (!isValid) {
return invalidSignatureResponse(400, { message: 'Signature invalid' });
}

const manager = OrderManager.getInstance();
await manager.addOrder(orderRequest.orderNumber, orderRequest.timeoutSeconds);
return manager.getOrders();
}

@Security(SecurityNames.LOCAL, securityGroups.orders.privileged)
@Delete('{orderNumber}')
@Response<string>(409, 'Endpoint is disabled in the server settings')
public async removeOrder(orderNumber: number) {
const manager = OrderManager.getInstance();
await manager.removeOrder(orderNumber);
return manager.getOrders();
}
}
43 changes: 43 additions & 0 deletions src/modules/orders/order-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { OrderEmitter } from '../events/order-emitter';
import { FeatureEnabled } from '../server-settings';
import OrderStore from './order-store';

@FeatureEnabled('Orders')
export default class OrderManager {
private static instance: OrderManager;

private orderStore: OrderStore;

private orderEmitter: OrderEmitter;

public static getInstance(): OrderManager {
if (!this.instance) {
this.instance = new OrderManager();
}
return this.instance;
}

public init(orderEmitter: OrderEmitter) {
if (this.orderEmitter) throw new Error('OrderEmitter is already initialized');
this.orderEmitter = orderEmitter;
this.orderStore = new OrderStore();
}

public async getOrders() {
return this.orderStore.orders;
}

public async addOrder(orderNumber: number, timeoutSeconds?: number) {
await this.orderStore.addOrder(orderNumber, timeoutSeconds);
const orders = await this.orderStore.orders;
this.orderEmitter.showOrders({ orders });
return orders;
}

public async removeOrder(orderNumber: number) {
await this.orderStore.removeOrder(orderNumber);
const orders = await this.orderStore.orders;
this.orderEmitter.showOrders({ orders });
return orders;
}
}
Loading
Loading