push: rolling in hacked up title server

This commit is contained in:
beerpsi 2023-11-23 23:35:07 +07:00
parent c22cc400d5
commit ddadb82f2f
60 changed files with 4018 additions and 2589 deletions

View File

@ -1,4 +1,5 @@
node_modules node_modules
js js
package.json package.json
*.config.* *.config.*
plugins

View File

@ -12,6 +12,7 @@
"typecheck": "tsc --project tsconfig.build.json --noEmit", "typecheck": "tsc --project tsconfig.build.json --noEmit",
"start": "pnpm build && pnpm start-no-build", "start": "pnpm build && pnpm start-no-build",
"start-no-build": "node js/main.js", "start-no-build": "node js/main.js",
"migrate": "typeorm-ts-node-esm migration:run -- -d src/external/db/data-source.ts",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"author": "beerpsi", "author": "beerpsi",
@ -21,9 +22,8 @@
"pnpm": "7" "pnpm": "7"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^9.1.1", "compression": "^1.7.4",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"drizzle-orm": "^0.29.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"fletcher": "^0.0.3", "fletcher": "^0.0.3",
@ -32,9 +32,12 @@
"luxon": "^3.4.4", "luxon": "^3.4.4",
"micro-packed": "^0.3.2", "micro-packed": "^0.3.2",
"raw-body": "^2.5.2", "raw-body": "^2.5.2",
"reflect-metadata": "^0.1.13",
"safe-json-stringify": "^1.2.0", "safe-json-stringify": "^1.2.0",
"sqlite3": "^5.1.6",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typed-struct": "^2.3.0", "typed-struct": "^2.3.0",
"typeorm": "^0.3.17",
"winston": "^3.11.0", "winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1", "winston-daily-rotate-file": "^4.7.1",
"zod": "^3.22.4", "zod": "^3.22.4",
@ -42,6 +45,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.7", "@types/better-sqlite3": "^7.6.7",
"@types/compression": "^1.7.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/iconv": "^3.0.4", "@types/iconv": "^3.0.4",
"@types/luxon": "^3.3.4", "@types/luxon": "^3.3.4",
@ -49,9 +53,9 @@
"@types/safe-json-stringify": "^1.1.5", "@types/safe-json-stringify": "^1.1.5",
"@typescript-eslint/eslint-plugin": "5.47.1", "@typescript-eslint/eslint-plugin": "5.47.1",
"@typescript-eslint/parser": "5.47.1", "@typescript-eslint/parser": "5.47.1",
"drizzle-kit": "^0.20.4",
"eslint": "8.18.0", "eslint": "8.18.0",
"eslint-plugin-cadence": "^0.1.0", "eslint-plugin-cadence": "^0.1.0",
"tap": "^18.6.1",
"typescript": "4.9.4" "typescript": "4.9.4"
} }
} }

File diff suppressed because it is too large Load Diff

27
src/external/db/data-source.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import "reflect-metadata";
import { AimeCard, AimeUser, FelicaCardLookup, FelicaMobileLookup } from "./entity/aimedb";
import { Arcade, Machine } from "./entity/allnet";
import { EventLog } from "./entity/base";
import { ChunithmStaticEvent } from "./entity/chunithm/static";
import { Config } from "lib/setup/config";
import { DataSource } from "typeorm";
export const AppDataSource = new DataSource({
type: "sqlite",
database: Config.DATABASE_PATH,
synchronize: true,
logging: false,
entities: [
Arcade,
AimeCard,
AimeUser,
ChunithmStaticEvent,
EventLog,
FelicaCardLookup,
FelicaMobileLookup,
Machine,
],
migrations: [],
subscribers: [],
enableWAL: true,
});

10
src/external/db/db.ts vendored
View File

@ -1,10 +0,0 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { Config } from "lib/setup/config";
const sqlite = new Database(Config.DATABASE_PATH);
export const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "src/external/db/drizzle" });

View File

@ -1,48 +0,0 @@
CREATE TABLE `aimedb_felica_card_lookup` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`idm` text(16) NOT NULL,
`access_code` text(20) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `aimedb_felica_mobile_lookup` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`idm` text(16) NOT NULL,
`access_code` text(20) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `allnet_arcade` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text(60) DEFAULT 'Random arcade at nowhere',
`nickname` text(40) DEFAULT 'Please send help',
`country` text(3) DEFAULT 'JPN',
`region_id` integer DEFAULT 1,
`region_name0` text(48) DEFAULT 'W',
`region_name1` text(48) DEFAULT '',
`region_name2` text(48) DEFAULT '',
`region_name3` text(48) DEFAULT '',
`utc_offset` real DEFAULT 9
);
--> statement-breakpoint
CREATE TABLE `allnet_arcade_ip` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`arcade_id` integer,
`ip` text(15),
FOREIGN KEY (`arcade_id`) REFERENCES `allnet_arcade`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `allnet_machine` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`arcade_id` integer,
`serial` text(11),
`game` text(5),
`can_venue_hop` integer,
`last_authenticated` integer,
FOREIGN KEY (`arcade_id`) REFERENCES `allnet_arcade`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `aimedb_felica_card_lookup_idm_unique` ON `aimedb_felica_card_lookup` (`idm`);--> statement-breakpoint
CREATE UNIQUE INDEX `aimedb_felica_card_lookup_access_code_unique` ON `aimedb_felica_card_lookup` (`access_code`);--> statement-breakpoint
CREATE UNIQUE INDEX `aimedb_felica_mobile_lookup_idm_unique` ON `aimedb_felica_mobile_lookup` (`idm`);--> statement-breakpoint
CREATE UNIQUE INDEX `aimedb_felica_mobile_lookup_access_code_unique` ON `aimedb_felica_mobile_lookup` (`access_code`);--> statement-breakpoint
CREATE UNIQUE INDEX `allnet_arcade_ip_ip_unique` ON `allnet_arcade_ip` (`ip`);--> statement-breakpoint
CREATE UNIQUE INDEX `allnet_machine_serial_unique` ON `allnet_machine` (`serial`);

View File

@ -1,29 +0,0 @@
CREATE TABLE `aimedb_card` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer,
`access_code` text(20) NOT NULL,
`created_date` integer NOT NULL,
`last_login_date` integer NOT NULL,
`is_locked` integer DEFAULT false NOT NULL,
`is_banned` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `aimedb_user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `aimedb_user` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`created_date` integer NOT NULL,
`last_login_date` integer NOT NULL,
`suspend_expiration_date` integer
);
--> statement-breakpoint
CREATE TABLE `event_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`timestamp` integer NOT NULL,
`system` text NOT NULL,
`type` text NOT NULL,
`severity` text NOT NULL,
`message` text,
`details` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `aimedb_card_access_code_unique` ON `aimedb_card` (`access_code`);

View File

@ -1,317 +0,0 @@
{
"version": "5",
"dialect": "sqlite",
"id": "a691ce52-7207-49ee-8450-d679c38b147a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"aimedb_felica_card_lookup": {
"name": "aimedb_felica_card_lookup",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"idm": {
"name": "idm",
"type": "text(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_code": {
"name": "access_code",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"aimedb_felica_card_lookup_idm_unique": {
"name": "aimedb_felica_card_lookup_idm_unique",
"columns": [
"idm"
],
"isUnique": true
},
"aimedb_felica_card_lookup_access_code_unique": {
"name": "aimedb_felica_card_lookup_access_code_unique",
"columns": [
"access_code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"aimedb_felica_mobile_lookup": {
"name": "aimedb_felica_mobile_lookup",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"idm": {
"name": "idm",
"type": "text(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_code": {
"name": "access_code",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"aimedb_felica_mobile_lookup_idm_unique": {
"name": "aimedb_felica_mobile_lookup_idm_unique",
"columns": [
"idm"
],
"isUnique": true
},
"aimedb_felica_mobile_lookup_access_code_unique": {
"name": "aimedb_felica_mobile_lookup_access_code_unique",
"columns": [
"access_code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_arcade": {
"name": "allnet_arcade",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text(60)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Random arcade at nowhere'"
},
"nickname": {
"name": "nickname",
"type": "text(40)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Please send help'"
},
"country": {
"name": "country",
"type": "text(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'JPN'"
},
"region_id": {
"name": "region_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"region_name0": {
"name": "region_name0",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'W'"
},
"region_name1": {
"name": "region_name1",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"region_name2": {
"name": "region_name2",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"region_name3": {
"name": "region_name3",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"utc_offset": {
"name": "utc_offset",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 9
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_arcade_ip": {
"name": "allnet_arcade_ip",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"arcade_id": {
"name": "arcade_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text(15)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"allnet_arcade_ip_ip_unique": {
"name": "allnet_arcade_ip_ip_unique",
"columns": [
"ip"
],
"isUnique": true
}
},
"foreignKeys": {
"allnet_arcade_ip_arcade_id_allnet_arcade_id_fk": {
"name": "allnet_arcade_ip_arcade_id_allnet_arcade_id_fk",
"tableFrom": "allnet_arcade_ip",
"tableTo": "allnet_arcade",
"columnsFrom": [
"arcade_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_machine": {
"name": "allnet_machine",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"arcade_id": {
"name": "arcade_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"serial": {
"name": "serial",
"type": "text(11)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"game": {
"name": "game",
"type": "text(5)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"can_venue_hop": {
"name": "can_venue_hop",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_authenticated": {
"name": "last_authenticated",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"allnet_machine_serial_unique": {
"name": "allnet_machine_serial_unique",
"columns": [
"serial"
],
"isUnique": true
}
},
"foreignKeys": {
"allnet_machine_arcade_id_allnet_arcade_id_fk": {
"name": "allnet_machine_arcade_id_allnet_arcade_id_fk",
"tableFrom": "allnet_machine",
"tableTo": "allnet_arcade",
"columnsFrom": [
"arcade_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -1,494 +0,0 @@
{
"version": "5",
"dialect": "sqlite",
"id": "ca3c0d34-cebd-4ea7-806e-f4eb141e586b",
"prevId": "a691ce52-7207-49ee-8450-d679c38b147a",
"tables": {
"aimedb_card": {
"name": "aimedb_card",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_code": {
"name": "access_code",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_login_date": {
"name": "last_login_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_locked": {
"name": "is_locked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_banned": {
"name": "is_banned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"aimedb_card_access_code_unique": {
"name": "aimedb_card_access_code_unique",
"columns": [
"access_code"
],
"isUnique": true
}
},
"foreignKeys": {
"aimedb_card_user_id_aimedb_user_id_fk": {
"name": "aimedb_card_user_id_aimedb_user_id_fk",
"tableFrom": "aimedb_card",
"tableTo": "aimedb_user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"aimedb_felica_card_lookup": {
"name": "aimedb_felica_card_lookup",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"idm": {
"name": "idm",
"type": "text(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_code": {
"name": "access_code",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"aimedb_felica_card_lookup_idm_unique": {
"name": "aimedb_felica_card_lookup_idm_unique",
"columns": [
"idm"
],
"isUnique": true
},
"aimedb_felica_card_lookup_access_code_unique": {
"name": "aimedb_felica_card_lookup_access_code_unique",
"columns": [
"access_code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"aimedb_felica_mobile_lookup": {
"name": "aimedb_felica_mobile_lookup",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"idm": {
"name": "idm",
"type": "text(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_code": {
"name": "access_code",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"aimedb_felica_mobile_lookup_idm_unique": {
"name": "aimedb_felica_mobile_lookup_idm_unique",
"columns": [
"idm"
],
"isUnique": true
},
"aimedb_felica_mobile_lookup_access_code_unique": {
"name": "aimedb_felica_mobile_lookup_access_code_unique",
"columns": [
"access_code"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"aimedb_user": {
"name": "aimedb_user",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"created_date": {
"name": "created_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_login_date": {
"name": "last_login_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspend_expiration_date": {
"name": "suspend_expiration_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_arcade": {
"name": "allnet_arcade",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text(60)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Random arcade at nowhere'"
},
"nickname": {
"name": "nickname",
"type": "text(40)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Please send help'"
},
"country": {
"name": "country",
"type": "text(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'JPN'"
},
"region_id": {
"name": "region_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"region_name0": {
"name": "region_name0",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'W'"
},
"region_name1": {
"name": "region_name1",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"region_name2": {
"name": "region_name2",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"region_name3": {
"name": "region_name3",
"type": "text(48)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"utc_offset": {
"name": "utc_offset",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 9
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_arcade_ip": {
"name": "allnet_arcade_ip",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"arcade_id": {
"name": "arcade_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text(15)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"allnet_arcade_ip_ip_unique": {
"name": "allnet_arcade_ip_ip_unique",
"columns": [
"ip"
],
"isUnique": true
}
},
"foreignKeys": {
"allnet_arcade_ip_arcade_id_allnet_arcade_id_fk": {
"name": "allnet_arcade_ip_arcade_id_allnet_arcade_id_fk",
"tableFrom": "allnet_arcade_ip",
"tableTo": "allnet_arcade",
"columnsFrom": [
"arcade_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"allnet_machine": {
"name": "allnet_machine",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"arcade_id": {
"name": "arcade_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"serial": {
"name": "serial",
"type": "text(11)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"game": {
"name": "game",
"type": "text(5)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"can_venue_hop": {
"name": "can_venue_hop",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_authenticated": {
"name": "last_authenticated",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"allnet_machine_serial_unique": {
"name": "allnet_machine_serial_unique",
"columns": [
"serial"
],
"isUnique": true
}
},
"foreignKeys": {
"allnet_machine_arcade_id_allnet_arcade_id_fk": {
"name": "allnet_machine_arcade_id_allnet_arcade_id_fk",
"tableFrom": "allnet_machine",
"tableTo": "allnet_arcade",
"columnsFrom": [
"arcade_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"event_log": {
"name": "event_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"system": {
"name": "system",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -1,20 +0,0 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1700532948929,
"tag": "0000_ambitious_gorilla_man",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1700570200478,
"tag": "0001_modern_stryfe",
"breakpoints": true
}
]
}

60
src/external/db/entity/aimedb.ts vendored Normal file
View File

@ -0,0 +1,60 @@
import { ConstructableBaseEntity } from "../utils/constructable-base-entity";
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
OneToMany,
} from "typeorm";
import { integer } from "types/misc";
@Entity("aimedb_user")
export class AimeUser extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@CreateDateColumn()
createdDate!: Date;
@OneToMany(() => AimeCard, (card) => card.user)
cards!: Array<AimeCard>;
}
@Entity("aimedb_card")
export class AimeCard extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@ManyToOne(() => AimeUser, (user) => user.cards, { eager: true, nullable: true })
user!: AimeUser | null;
@Column({ unique: true, length: 20 })
accessCode!: string;
@CreateDateColumn()
createdDate!: Date;
@Column({ type: "boolean", default: false })
isLocked!: boolean;
@Column({ type: "boolean", default: false })
isBanned!: boolean;
}
export class FelicaBaseLookup extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@Column({ unique: true, length: 16 })
idm!: string;
@Column({ unique: true, length: 20 })
accessCode!: string;
}
@Entity("aimedb_felica_mobile_lookup")
export class FelicaMobileLookup extends FelicaBaseLookup {}
@Entity("aimedb_felica_card_lookup")
export class FelicaCardLookup extends FelicaBaseLookup {}

57
src/external/db/entity/allnet.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import { ConstructableBaseEntity } from "../utils/constructable-base-entity";
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, OneToOne } from "typeorm";
import { integer } from "types/misc";
@Entity("allnet_arcade")
export class Arcade extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@Column({ length: 60 })
name!: string;
@Column({ length: 40 })
nickname!: string;
@Column({ length: 3 })
country!: string;
@Column()
regionId!: integer;
@Column({ length: 48 })
regionName0!: string;
@Column({ length: 48 })
regionName1!: string;
@Column({ length: 48 })
regionName2!: string;
@Column({ length: 48 })
regionName3!: string;
@OneToMany(() => Machine, (machine) => machine.arcade)
machines!: Array<Machine>;
}
@Entity("allnet_machine")
export class Machine extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@OneToOne(() => Arcade)
arcade!: Arcade;
@Column({ length: 11, unique: true })
serial!: string;
@Column({ length: 5 })
game!: string;
@Column({ default: false })
canVenueHop!: boolean;
@Column({ type: "datetime", nullable: true })
lastAuthenticated!: Date | null;
}

27
src/external/db/entity/base.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import { ConstructableBaseEntity } from "../utils/constructable-base-entity";
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
import { integer } from "types/misc";
@Entity("event_log")
export class EventLog extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@CreateDateColumn()
datetime!: Date;
@Column({ type: "text" })
system!: string;
@Column({ type: "text" })
type!: string;
@Column({ length: 10 })
severity!: string;
@Column({ type: "text" })
message!: string;
@Column({ type: "simple-json" })
details!: Record<string, unknown>;
}

View File

View File

@ -0,0 +1,64 @@
import { ConstructableBaseEntity } from "../../utils/constructable-base-entity";
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
OneToMany,
Unique,
} from "typeorm";
import { integer } from "types/misc";
@Entity("chunithm_static_events")
@Unique(["version", "eventId"])
export class ChunithmStaticEvent extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@Column()
version!: integer;
@Column()
eventId!: integer;
@Column()
type!: integer;
@Column({ type: "text" })
name!: string;
@Column({ type: "datetime", default: () => "CURRENT_TIMESTAMP" })
startDate!: Date;
@Column({ type: "boolean", default: true })
enabled!: boolean;
}
@Entity("chunithm_static_events")
@Unique(["version", "chargeId"])
export class ChunithmStaticCharge extends ConstructableBaseEntity {
@PrimaryGeneratedColumn()
id!: integer;
@Column()
version!: integer;
@Column()
chargeId!: integer;
@Column({ type: "text" })
name!: string;
@Column()
expirationDays!: integer;
@Column()
consumeType!: integer;
@Column({ type: "boolean" })
sellingAppeal!: boolean;
@Column({ type: "boolean", default: true })
enabled!: boolean;
}

View File

@ -1,67 +0,0 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
export const felicaCardLookup = sqliteTable(
"aimedb_felica_card_lookup",
{
id: integer("id").primaryKey({ autoIncrement: true }),
idm: text("idm", { length: 16 }).notNull(),
accessCode: text("access_code", { length: 20 }).notNull(),
},
(t) => ({
unqIdm: unique().on(t.idm),
unqAc: unique().on(t.accessCode),
})
);
export type FelicaCardLookup = typeof felicaMobileLookup.$inferSelect;
export type NewFelicaCardLookup = typeof felicaMobileLookup.$inferSelect;
export const felicaMobileLookup = sqliteTable(
"aimedb_felica_mobile_lookup",
{
id: integer("id").primaryKey({ autoIncrement: true }),
idm: text("idm", { length: 16 }).notNull(),
accessCode: text("access_code", { length: 20 }).notNull(),
},
(t) => ({
unqIdm: unique().on(t.idm),
unqAc: unique().on(t.accessCode),
})
);
export type FelicaMobileLookup = typeof felicaMobileLookup.$inferSelect;
export type NewFelicaMobileLookup = typeof felicaMobileLookup.$inferInsert;
export const user = sqliteTable("aimedb_user", {
id: integer("id").primaryKey({ autoIncrement: true }),
createdDate: integer("created_date", { mode: "timestamp" })
.notNull()
.$default(() => new Date()),
lastLoginDate: integer("last_login_date", { mode: "timestamp" })
.notNull()
.$default(() => new Date()),
suspendExpirationDate: integer("suspend_expiration_date", { mode: "timestamp" }),
});
export type AimeUser = typeof user.$inferSelect;
export type NewAimeUser = typeof user.$inferInsert;
export const card = sqliteTable(
"aimedb_card",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").references(() => user.id),
accessCode: text("access_code", { length: 20 }).notNull(),
createdDate: integer("created_date", { mode: "timestamp" })
.notNull()
.$default(() => new Date()),
lastLoginDate: integer("last_login_date", { mode: "timestamp" })
.notNull()
.$default(() => new Date()),
isLocked: integer("is_locked", { mode: "boolean" }).default(false).notNull(),
isBanned: integer("is_banned", { mode: "boolean" }).default(false).notNull(),
},
(t) => ({
unqAc: unique().on(t.accessCode),
})
);

View File

@ -1,56 +0,0 @@
/**
* In theory you should be able to just modify the imports here if you want to
* use a different database driver.
*/
import { integer, text, real, sqliteTable } from "drizzle-orm/sqlite-core";
export const arcade = sqliteTable("allnet_arcade", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 60 }).default("Random arcade at nowhere"),
nickname: text("nickname", { length: 40 }).default("Please send help"),
country: text("country", { length: 3 }).default("JPN"),
/**
* Largest to smallest units of administrative division
*/
regionId: integer("region_id").default(1),
regionName0: text("region_name0", { length: 48 }).default("W"),
regionName1: text("region_name1", { length: 48 }).default(""),
regionName2: text("region_name2", { length: 48 }).default(""),
regionName3: text("region_name3", { length: 48 }).default(""),
/**
* Client timezone. There's probably no arcades that span
* 2 timezones, right...?
*/
utcOffset: real("utc_offset").default(9),
});
export type Arcade = typeof arcade.$inferSelect;
export type NewArcade = typeof arcade.$inferInsert;
export const arcadeIp = sqliteTable("allnet_arcade_ip", {
id: integer("id").primaryKey({ autoIncrement: true }),
arcade_id: integer("arcade_id").references(() => arcade.id),
ip: text("ip", { length: 15 }).unique(),
});
export type ArcadeIp = typeof arcadeIp.$inferSelect;
export type NewArcadeIp = typeof arcadeIp.$inferInsert;
export const machine = sqliteTable("allnet_machine", {
id: integer("id").primaryKey({ autoIncrement: true }),
arcade_id: integer("arcade_id").references(() => arcade.id),
serial: text("serial", { length: 11 }).unique(),
game: text("game", { length: 5 }),
canVenueHop: integer("can_venue_hop", { mode: "boolean" }),
lastAuthenticated: integer("last_authenticated", { mode: "timestamp" }),
});
export type Machine = typeof machine.$inferSelect;
export type NewMachine = typeof machine.$inferInsert;

View File

@ -1,16 +0,0 @@
import { integer, text, sqliteTable } from "drizzle-orm/sqlite-core";
export const eventLog = sqliteTable("event_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp", { mode: "timestamp" })
.notNull()
.$default(() => new Date()),
system: text("system").notNull(),
type: text("type").notNull(),
severity: text("severity").notNull(),
message: text("message"),
details: text("details", { mode: "json" }),
});
export type EventLog = typeof eventLog.$inferSelect;
export type NewEventLog = typeof eventLog.$inferInsert;

View File

@ -1,3 +0,0 @@
export * from "./aimedb";
export * from "./allnet";
export * from "./base";

View File

@ -0,0 +1,7 @@
import { BaseEntity } from "typeorm";
export class ConstructableBaseEntity extends BaseEntity {
static construct<T extends BaseEntity>(this: new () => T, params: Partial<T>): T {
return Object.assign(new this(), params);
}
}

View File

@ -63,6 +63,8 @@ export const enum PortalRegistration {
} }
export const enum CompanyCodes { export const enum CompanyCodes {
/** Not really, could be FeliCa Mobile. */
INVALID = 0,
SEGA = 1, SEGA = 1,
BANDAI_NAMCO = 2, BANDAI_NAMCO = 2,
KONAMI = 3, KONAMI = 3,

View File

@ -0,0 +1,14 @@
import { CompanyCodes } from "./aimedb";
export const FELICA_MOBILE_IC_TYPES = [
0x06, 0x07, 0x10, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18,
] as const;
export const FELICA_CARD_IC_TYPES = [0x20, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7] as const;
export const DATA_FORMAT_CODE_MAP: Record<number, number> = {
0x002a: CompanyCodes.BANDAI_NAMCO,
0x003a: CompanyCodes.BANDAI_NAMCO,
0x8ca2: CompanyCodes.BANDAI_NAMCO,
0x0068: CompanyCodes.KONAMI,
0x0078: CompanyCodes.SEGA,
};

View File

@ -0,0 +1,19 @@
export const enum ChunithmVersions {
CHUNITHM = 100,
CHUNITHM_PLUS = 105,
AIR = 110,
AIR_PLUS = 115,
STAR = 120,
STAR_PLUS = 125,
AMAZON = 130,
AMAZON_PLUS = 135,
CRYSTAL = 140,
CRYSTAL_PLUS = 145,
/**
* @note PARADISE and PARADISE LOST share the same game version.
*/
PARADISE = 150,
NEW = 200,
NEW_PLUS = 205,
SUN = 210,
}

View File

@ -24,34 +24,89 @@ const config: unknown = JSON5.parse(configFile);
const zod16bitNumber = z.number().gt(0).lte(65535); const zod16bitNumber = z.number().gt(0).lte(65535);
const zodLogLevel = z.enum(["crit", "debug", "error", "info", "verbose", "warn"]); const zodLogLevel = z.enum(["crit", "debug", "error", "info", "verbose", "warn"]);
const zodOptionalHexString16 = z const zodHexString = z.string().regex(/^[0-9a-z]+$/iu, "value is not a hex string");
.string() const zodHexString16 = zodHexString.length(16);
.length(16)
.regex(/^[0-9a-z]+$/iu, "value is not a hex string")
.optional();
const configSchema = z.object({ const configSchema = z.object({
NAME: z.string(), NAME: z.string().default("Kozukata Toa"),
DATABASE_PATH: z.string(), DATABASE_PATH: z.string().default("data/db.sqlite3"),
LISTEN_ADDRESS: z.string().ip(), LISTEN_ADDRESS: z.string().ip().default("0.0.0.0"),
LOGGER_CONFIG: z.object({ LOGGER_CONFIG: z.object({
LOG_LEVEL: zodLogLevel, LOG_LEVEL: zodLogLevel.default("info"),
CONSOLE: z.boolean(), CONSOLE: z.boolean().default(true),
FOLDER: z.string().optional(), FOLDER: z.string().default("logs"),
}), }),
ALLNET_CONFIG: z.object({ ALLNET_CONFIG: z.object({
ENABLED: z.boolean(), ENABLED: z.boolean().default(true),
PORT: zod16bitNumber, PORT: zod16bitNumber.default(80),
ALLOW_UNREGISTERED_SERIALS: z.boolean(), ALLOW_UNREGISTERED_SERIALS: z.boolean().default(true),
UPDATE_CFG_FOLDER: z.string().optional(), UPDATE_CFG_FOLDER: z.string().optional(),
}), }),
AIMEDB_CONFIG: z.object({ AIMEDB_CONFIG: z.object({
ENABLED: z.boolean(), ENABLED: z.boolean().default(true),
PORT: zod16bitNumber, PORT: zod16bitNumber.default(22345),
KEY: z.string().length(16).optional(), KEY: z.string().length(16).default("Copyright(C)SEGA"),
AIME_MOBILE_CARD_KEY: zodOptionalHexString16, AIME_MOBILE_CARD_KEY: zodHexString16.default("5CD3E81B9024F67A"),
RESERVED_CARD_PREFIX: z.string().length(5).optional(), RESERVED_CARD_PREFIX: z.string().length(5).default("01053"),
RESERVED_CARD_KEY: zodOptionalHexString16, RESERVED_CARD_KEY: zodHexString16.default("E8179645DB3FC02A"),
}),
TITLES_CONFIG: z.object({
ENABLED: z.boolean().default(true),
PORT: zod16bitNumber.default(8080),
HOSTNAME: z.string().default("localhost"),
}),
CHUNITHM_CONFIG: z.object({
ENABLE: z.boolean().default(true),
MODS: z
.object({
TEAM_NAME: z.string().optional(),
USE_LOGIN_BONUS: z.boolean().default(false),
FORCE_UNLOCK_ALL: z.boolean().default(false),
})
.default({ USE_LOGIN_BONUS: false, FORCE_UNLOCK_ALL: false }),
VERSIONS: z
.record(
z.object({
rom: z.string(),
data: z.string(),
})
)
.default({
"200": {
rom: "2.00.00",
data: "2.00.00",
},
"205": {
rom: "2.05.00",
data: "2.05.00",
},
"210": {
rom: "2.10.00",
data: "2.10.00",
},
}),
CRYPTO: z
.object({
ENCRYPTED_ONLY: z.boolean().default(false),
KEYS: z.record(z.array(zodHexString).min(2).max(3)).optional(),
})
.refine((arg) => {
if (arg.ENCRYPTED_ONLY) {
return !!arg.KEYS && Object.keys(arg.KEYS).length >= 1;
}
return true;
}, "Must provide keys for at least one version if running in encrypted only mode.")
.default({
ENCRYPTED_ONLY: false,
KEYS: {
"210": [
"75695c3d265f434c3953454c5830522b4b3c4d7b42482a312e5627216b2b4060",
"31277c37707044377141595058345a6b",
"04780206ca5f36f4",
],
},
}),
}), }),
}); });
@ -64,3 +119,23 @@ if (!parseResult.success) {
} }
export const Config = parseResult.data; export const Config = parseResult.data;
// Environment Variable Validation
const nodeEnv = process.env.NODE_ENV ?? "";
if (!nodeEnv) {
logger.error(`No NODE_ENV specified in environment. Terminating.`);
process.exit(1);
}
if (!["dev", "production", "staging", "test"].includes(nodeEnv)) {
logger.error(
`Invalid NODE_ENV set in environment. Expected dev, production, test or staging. Got ${nodeEnv}.`
);
process.exit(1);
}
export const Environment = {
nodeEnv: nodeEnv as "dev" | "production" | "staging" | "test",
};

View File

@ -1,19 +1,41 @@
import { aimeDbServerFactory, allnetServer } from "./servers/index"; import { aimeDbServerFactory, allnetServer, titleServer } from "./servers/index";
import { AppDataSource } from "external/db/data-source";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config"; import { Config } from "lib/setup/config";
import net from "net"; import net from "net";
import "reflect-metadata";
const logger = CreateLogCtx(__filename); const logger = CreateLogCtx(__filename);
logger.info(`Booting ${Config.NAME}.`, { bootInfo: true }); logger.info(`Booting ${Config.NAME}.`, { bootInfo: true });
logger.info(`Log level is set to ${Config.LOGGER_CONFIG.LOG_LEVEL}.`, { bootInfo: true }); logger.info(`Log level is set to ${Config.LOGGER_CONFIG.LOG_LEVEL}.`, { bootInfo: true });
if (Config.ALLNET_CONFIG.ENABLED) { const start = process.hrtime.bigint();
allnetServer.listen(Config.ALLNET_CONFIG.PORT, Config.LISTEN_ADDRESS);
}
if (Config.AIMEDB_CONFIG.ENABLED) { logger.info("Initializing database connection...");
const server = net.createServer(aimeDbServerFactory()); void AppDataSource.initialize().then(() => {
const end = process.hrtime.bigint();
server.listen(Config.AIMEDB_CONFIG.PORT, Config.LISTEN_ADDRESS); logger.info(`Database connected in ${(end - start) / 1000000n}ms`);
}
if (Config.ALLNET_CONFIG.ENABLED) {
logger.info(
`Starting ALL.Net server on ${Config.LISTEN_ADDRESS}:${Config.ALLNET_CONFIG.PORT}.`
);
allnetServer.listen(Config.ALLNET_CONFIG.PORT, Config.LISTEN_ADDRESS);
}
if (Config.TITLES_CONFIG.ENABLED) {
logger.info(
`Starting title server on ${Config.LISTEN_ADDRESS}:${Config.ALLNET_CONFIG.PORT}.`
);
titleServer.listen(Config.TITLES_CONFIG.PORT, Config.LISTEN_ADDRESS);
}
if (Config.AIMEDB_CONFIG.ENABLED) {
const server = net.createServer(aimeDbServerFactory());
logger.info(`Starting AimeDB on ${Config.LISTEN_ADDRESS}:${Config.ALLNET_CONFIG.PORT}.`);
server.listen(Config.AIMEDB_CONFIG.PORT, Config.LISTEN_ADDRESS);
}
});

View File

@ -5,10 +5,8 @@ import {
AimeAccountExtendedQueryStruct, AimeAccountExtendedQueryStruct,
} from "../types/aime-account"; } from "../types/aime-account";
import { PacketHeaderStruct } from "../types/header"; import { PacketHeaderStruct } from "../types/header";
import { CommandId, PortalRegistration, ResultCodes } from "../utils/misc"; import { CommandId, PortalRegistration, ResultCodes } from "../../../lib/constants/aimedb";
import { eq } from "drizzle-orm"; import { AimeCard } from "external/db/entity/aimedb";
import { db } from "external/db/db";
import { card } from "external/db/schemas";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import type { AimeDBHandlerFn } from "../types/handlers"; import type { AimeDBHandlerFn } from "../types/handlers";
@ -39,25 +37,23 @@ export const GetAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse"> = asy
const accessCode = Buffer.from(req.accessCode).toString("hex"); const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db const card = await AimeCard.findOne({
.select() where: { accessCode },
.from(card) });
.where(eq(card.accessCode, accessCode))
.then((r) => r[0]);
if (!cardRow) { if (!card) {
return resp; return resp;
} }
if (cardRow.isBanned && cardRow.isLocked) { if (card.isBanned && card.isLocked) {
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK; header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
} else if (cardRow.isBanned) { } else if (card.isBanned) {
header.result = ResultCodes.BAN_SYSTEM_LOCK; header.result = ResultCodes.BAN_SYSTEM_LOCK;
} else if (cardRow.isLocked) { } else if (card.isLocked) {
header.result = ResultCodes.USER_LOCK; header.result = ResultCodes.USER_LOCK;
} }
resp.accountId = cardRow.id; resp.accountId = card.id;
return resp; return resp;
}; };
@ -86,31 +82,29 @@ export const RegisterAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse">
const accessCode = Buffer.from(req.accessCode).toString("hex"); const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db const card = await AimeCard.findOne({
.select() where: {
.from(card) accessCode,
.where(eq(card.accessCode, accessCode)) },
.then((r) => r[0]); });
if (cardRow) { if (card) {
header.result = ResultCodes.ID_ALREADY_REGISTERED; header.result = ResultCodes.ID_ALREADY_REGISTERED;
resp.accountId = cardRow.id; resp.accountId = card.id;
return resp; return resp;
} }
const newCardRow = await db const newCard = AimeCard.construct({ accessCode });
.insert(card)
.values({ accessCode })
.returning()
.then((r) => r[0]);
if (!newCardRow) { try {
logger.crit("Failed to insert new lookup entry into the database.", { accessCode }); await newCard.save();
} catch (err) {
logger.crit("Failed to insert new lookup entry into the database.", { err });
header.result = ResultCodes.UNKNOWN_ERROR; header.result = ResultCodes.UNKNOWN_ERROR;
return resp; return resp;
} }
resp.accountId = newCardRow.id; resp.accountId = newCard.id;
return resp; return resp;
}; };
@ -126,7 +120,6 @@ export const GetAimeAccountExtendedHandler: AimeDBHandlerFn<"AimeAccountExtended
const resp = new AimeAccountExtendedResponseStruct(); const resp = new AimeAccountExtendedResponseStruct();
// TODO: Actually handle portal state when we get a webUI // TODO: Actually handle portal state when we get a webUI
// TODO: What the fuck is an auth key
resp.portalRegistered = PortalRegistration.UNREGISTERED; resp.portalRegistered = PortalRegistration.UNREGISTERED;
resp.accountId = -1; resp.accountId = -1;
@ -140,25 +133,23 @@ export const GetAimeAccountExtendedHandler: AimeDBHandlerFn<"AimeAccountExtended
const accessCode = Buffer.from(req.accessCode).toString("hex"); const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db const card = await AimeCard.findOne({
.select() where: { accessCode },
.from(card) });
.where(eq(card.accessCode, accessCode))
.then((r) => r[0]);
if (!cardRow) { if (!card) {
resp.accountId = -1; resp.accountId = -1;
return resp; return resp;
} }
if (cardRow.isBanned && cardRow.isLocked) { if (card.isBanned && card.isLocked) {
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK; header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
} else if (cardRow.isBanned) { } else if (card.isBanned) {
header.result = ResultCodes.BAN_SYSTEM_LOCK; header.result = ResultCodes.BAN_SYSTEM_LOCK;
} else if (cardRow.isLocked) { } else if (card.isLocked) {
header.result = ResultCodes.USER_LOCK; header.result = ResultCodes.USER_LOCK;
} }
resp.accountId = cardRow.id; resp.accountId = card.id;
return resp; return resp;
}; };

View File

@ -5,9 +5,9 @@ import {
StatusLogStruct, StatusLogStruct,
} from "../types/aime-log"; } from "../types/aime-log";
import { PacketHeaderStruct } from "../types/header"; import { PacketHeaderStruct } from "../types/header";
import { CommandId, LogStatus, ResultCodes } from "../utils/misc"; import { CommandId, LogStatus, ResultCodes } from "../../../lib/constants/aimedb";
import { db } from "external/db/db"; import { AppDataSource } from "external/db/data-source";
import { eventLog } from "external/db/schemas"; import { EventLog } from "external/db/entity/base";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import type { AimeDBHandlerFn } from "../types/handlers"; import type { AimeDBHandlerFn } from "../types/handlers";
@ -27,13 +27,15 @@ export const StatusLogHandler: AimeDBHandlerFn = async (header, data) => {
return null; return null;
} }
await db.insert(eventLog).values({ const eventLog = EventLog.construct({
system: "aimedb", system: "aimedb",
type: `AIMEDB_LOG_${statusName}`, type: `LOG_${statusName}`,
severity: "info", severity: "info",
details: { aimeId: req.aimeId }, details: { aimeId: req.aimeId },
}); });
await eventLog.save();
return null; return null;
}; };
@ -51,9 +53,9 @@ export const AimeLogHandler: AimeDBHandlerFn = async (header, data) => {
return null; return null;
} }
await db.insert(eventLog).values({ const eventLog = EventLog.construct({
system: "aimedb", system: "aimedb",
type: `AIMEDB_LOG_${statusName}`, type: `LOG_${statusName}`,
severity: "info", severity: "info",
details: { details: {
aimeId: req.aimeId, aimeId: req.aimeId,
@ -64,6 +66,8 @@ export const AimeLogHandler: AimeDBHandlerFn = async (header, data) => {
}, },
}); });
await eventLog.save();
return null; return null;
}; };
@ -78,7 +82,7 @@ export const AimeExtendedLogHandler: AimeDBHandlerFn<"AimeLogExtendedResponse">
const req = new ExtendedAimeLogStruct(data); const req = new ExtendedAimeLogStruct(data);
const resp = new AimeLogExtendedResponseStruct(); const resp = new AimeLogExtendedResponseStruct();
await db.transaction(async (tx) => { await AppDataSource.transaction(async (em) => {
const ops = []; const ops = [];
for (let i = 0; i < req.count; i++) { for (let i = 0; i < req.count; i++) {
@ -100,21 +104,20 @@ export const AimeExtendedLogHandler: AimeDBHandlerFn<"AimeLogExtendedResponse">
continue; continue;
} }
ops.push( const eventLog = EventLog.construct({
tx.insert(eventLog).values({ system: "aimedb",
system: "aimedb", type: `LOG_${statusName}`,
type: `AIMEDB_LOG_${statusName}`, severity: "info",
severity: "info", details: {
details: { aimeId: entry.aimeId,
aimeId: entry.aimeId, userId: entry.userId.toString(),
userId: entry.userId.toString(), creditCount: entry.creditCount,
creditCount: entry.creditCount, betCount: entry.betCount,
betCount: entry.betCount, wonCount: entry.wonCount,
wonCount: entry.wonCount, },
}, });
})
); ops.push(em.save(eventLog));
resp.result[i] = 1;
} }
await Promise.all(ops); await Promise.all(ops);

View File

@ -5,7 +5,7 @@ import {
OldCampaignResponseStruct, OldCampaignResponseStruct,
} from "../types/campaign"; } from "../types/campaign";
import { PacketHeaderStruct } from "../types/header"; import { PacketHeaderStruct } from "../types/header";
import { CommandId, ResultCodes } from "../utils/misc"; import { CommandId, ResultCodes } from "../../../lib/constants/aimedb";
import type { AimeDBHandlerFn } from "../types/handlers"; import type { AimeDBHandlerFn } from "../types/handlers";
export const GetCampaignInfoHandler: AimeDBHandlerFn<"CampaignResponse" | "OldCampaignResponse"> = ( export const GetCampaignInfoHandler: AimeDBHandlerFn<"CampaignResponse" | "OldCampaignResponse"> = (

View File

@ -17,29 +17,29 @@ import {
import { PacketHeaderStruct } from "../types/header"; import { PacketHeaderStruct } from "../types/header";
import { CalculateAccessCode } from "../utils/access-code"; import { CalculateAccessCode } from "../utils/access-code";
import { IsSupportedFelicaMobile, IsSupportedFelica } from "../utils/felica"; import { IsSupportedFelicaMobile, IsSupportedFelica } from "../utils/felica";
import { CommandId, CompanyCodes, PortalRegistration, ResultCodes } from "../utils/misc"; import { AimeCard, FelicaCardLookup, FelicaMobileLookup } from "external/db/entity/aimedb";
import { desc, eq } from "drizzle-orm"; import { CommandId, CompanyCodes, PortalRegistration, ResultCodes } from "lib/constants/aimedb";
import { db } from "external/db/db"; import { DATA_FORMAT_CODE_MAP } from "lib/constants/felica";
import { card, felicaCardLookup, felicaMobileLookup } from "external/db/schemas/index";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config"; import { Config } from "lib/setup/config";
import type { AimeDBHandlerFn } from "../types/handlers"; import type { AimeDBHandlerFn } from "../types/handlers";
import type { ValidFeliCaIcTypes } from "../utils/felica";
import type { FelicaBaseLookup } from "external/db/entity/aimedb";
const logger = CreateLogCtx(__filename); const logger = CreateLogCtx(__filename);
async function createAndInsertNewAccessCode( async function generateAndInsertNewAccessCode<T extends typeof FelicaBaseLookup>(
table: typeof felicaCardLookup | typeof felicaMobileLookup, table: T,
cardType: "FeliCa Card" | "FeliCa Mobile", cardType: "FeliCa Card" | "FeliCa Mobile",
idm: bigint, idm: bigint,
prefix: string, prefix: string,
key: string key: string
) { ): Promise<FelicaBaseLookup | null> {
const mostRecentRow = await db const mostRecentRow = await table.findOne({
.select() order: {
.from(table) id: "desc",
.orderBy(desc(table.id)) },
.limit(1) });
.then((r) => r[0]);
const nextId = mostRecentRow ? mostRecentRow.id + 1 : 1; const nextId = mostRecentRow ? mostRecentRow.id + 1 : 1;
@ -50,21 +50,59 @@ async function createAndInsertNewAccessCode(
accessCode, accessCode,
}); });
const value = { idm: idm.toString(16), accessCode }; const card = table.construct({ idm: idm.toString(16), accessCode });
const result = await db try {
.insert(felicaCardLookup) await card.save();
.values(value) } catch (err) {
.returning() logger.error("Failed to insert new lookup entry into the database.", { err });
.then((r) => r[0]);
if (!result) {
logger.crit("Failed to insert new lookup entry into the database.", value);
return null; return null;
} }
return result; return card;
}
async function generateNewFeliCa(idm: bigint, icType: ValidFeliCaIcTypes) {
if (!IsSupportedFelica(icType)) {
throw new Error("Invalid IC type.");
}
if (IsSupportedFelicaMobile(icType)) {
if (!Config.AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY) {
logger.error(
"AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY is not set in config file. Cannot generate a new access code.",
{ idm, icType }
);
return null;
}
return generateAndInsertNewAccessCode(
FelicaMobileLookup,
"FeliCa Mobile",
idm,
"01035",
Config.AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY
);
}
if (!Config.AIMEDB_CONFIG.RESERVED_CARD_KEY || !Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX) {
logger.error(
"AIMEDB_CONFIG.RESERVED_CARD_KEY or AIMEDB_CONFIG.RESERVED_CARD_PREFIX is not set in config file. Cannot generate a new access code.",
{ idm, icType }
);
return null;
}
return generateAndInsertNewAccessCode(
FelicaCardLookup,
"FeliCa Card",
idm,
Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX,
Config.AIMEDB_CONFIG.RESERVED_CARD_KEY
);
} }
// This is supposed to be just a lookup handler, but my guess is that most games assume that there // This is supposed to be just a lookup handler, but my guess is that most games assume that there
@ -82,44 +120,22 @@ export const FelicaLookupHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = asyn
const req = new FelicaLookupRequestStruct(data); const req = new FelicaLookupRequestStruct(data);
const resp = new FelicaLookupResponseStruct(); const resp = new FelicaLookupResponseStruct();
if (!IsSupportedFelica(req.osVer)) { if (!IsSupportedFelica(req.icType)) {
header.result = ResultCodes.INVALID_AIME_ID; header.result = ResultCodes.INVALID_AIME_ID;
resp.felicaIndex = -1; resp.felicaIndex = -1;
return resp; return resp;
} }
const table = IsSupportedFelicaMobile(req.osVer) ? felicaMobileLookup : felicaCardLookup; const table = IsSupportedFelicaMobile(req.icType) ? FelicaMobileLookup : FelicaCardLookup;
let result = await db let result: FelicaBaseLookup | null = await table.findOne({
.select() where: {
.from(table) idm: req.idm.toString(16),
.where(eq(table.idm, req.idm.toString(16))) },
.then((r) => r[0]); });
if (!result) { if (!result) {
// Exit early if card is FeliCa mobile, so the game can use the register endpoint const tmp = await generateNewFeliCa(req.idm, req.icType);
if (IsSupportedFelicaMobile(req.osVer)) {
resp.felicaIndex = -1;
return resp;
}
if (!Config.AIMEDB_CONFIG.RESERVED_CARD_KEY || !Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX) {
logger.error(
"AIMEDB_CONFIG.RESERVED_CARD_KEY or AIMEDB_CONFIG.RESERVED_CARD_PREFIX is not set in config file. Cannot generate a new access code.",
{ req }
);
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
const tmp = await createAndInsertNewAccessCode(
felicaCardLookup,
"FeliCa Card",
req.idm,
Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX,
Config.AIMEDB_CONFIG.RESERVED_CARD_KEY
);
if (!tmp) { if (!tmp) {
header.result = ResultCodes.UNKNOWN_ERROR; header.result = ResultCodes.UNKNOWN_ERROR;
@ -160,94 +176,46 @@ export const FelicaExtendedLookupHandler: AimeDBHandlerFn<"FelicaExtendedLookupR
return resp; return resp;
} }
if (!IsSupportedFelica(req.osVer)) { if (!IsSupportedFelica(req.icType)) {
header.result = ResultCodes.INVALID_AIME_ID; header.result = ResultCodes.INVALID_AIME_ID;
return resp; return resp;
} }
let result; const table = IsSupportedFelicaMobile(req.icType) ? FelicaMobileLookup : FelicaCardLookup;
if (IsSupportedFelicaMobile(req.osVer)) { const result: FelicaBaseLookup | null = await table.findOne({
result = await db where: {
.select() idm: req.idm.toString(16),
.from(felicaMobileLookup) },
.where(eq(felicaMobileLookup.idm, req.idm.toString(16))) });
.leftJoin(card, eq(card.accessCode, felicaMobileLookup.accessCode))
.then((r) => r[0]); let accessCode;
} else {
result = await db
.select()
.from(felicaCardLookup)
.where(eq(felicaCardLookup.idm, req.idm.toString(16)))
.leftJoin(card, eq(card.accessCode, felicaCardLookup.accessCode))
.then((r) => r[0]);
}
if (result) { if (result) {
const lookupResult = accessCode = result.accessCode;
"aimedb_felica_mobile_lookup" in result } else {
? result.aimedb_felica_mobile_lookup const row = await generateNewFeliCa(req.idm, req.icType);
: result.aimedb_felica_card_lookup;
const cardResult = result.aimedb_card;
// HACK: Since we cannot possibly know who made it (even AICC cards have if (!row) {
// the same manufacturer code `01:2e`!), we're just going to treat everything header.result = ResultCodes.UNKNOWN_ERROR;
// as a SEGA card. return resp;
resp.companyCode = CompanyCodes.SEGA;
resp.accessCode.set(Buffer.from(lookupResult.accessCode, "hex"));
if (cardResult) {
resp.accountId = cardResult.id;
resp.portalRegistered = PortalRegistration.UNREGISTERED;
} }
return resp; accessCode = row.accessCode;
} }
// Assuming that FeliCa Mobile is handled by their own registration endpoint... // HACK: What does official AimeDB do if the DFC is not valid?
if (IsSupportedFelicaMobile(req.osVer)) { resp.companyCode = DATA_FORMAT_CODE_MAP[req.dataFormatCode] ?? CompanyCodes.SEGA;
return resp; resp.accessCode.set(Buffer.from(accessCode, "hex"));
}
// Card is not in the lookup tables, register a new card... const maybeCard = await AimeCard.findOne({
if (!Config.AIMEDB_CONFIG.RESERVED_CARD_KEY || !Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX) { where: { accessCode },
logger.error( });
"AIMEDB_CONFIG.RESERVED_CARD_KEY or AIMEDB_CONFIG.RESERVED_CARD_PREFIX is not set in config file. Cannot generate a new access code.",
{ req }
);
header.result = ResultCodes.INVALID_AIME_ID; if (maybeCard) {
return resp; // TODO: Actually handle portal state when we get a webUI
}
const row = await createAndInsertNewAccessCode(
felicaCardLookup,
"FeliCa Card",
req.idm,
Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX,
Config.AIMEDB_CONFIG.RESERVED_CARD_KEY
);
if (!row) {
header.result = ResultCodes.UNKNOWN_ERROR;
return resp;
}
// HACK: Since we cannot possibly know who made it (even AICC cards have
// the same manufacturer code `01:2e`!), we're just going to treat everything
// as a SEGA card.
resp.companyCode = CompanyCodes.SEGA;
resp.accessCode.set(Buffer.from(row.accessCode, "hex"));
const cardResult = await db
.select()
.from(card)
.where(eq(card.accessCode, row.accessCode))
.then((r) => r[0]);
if (cardResult) {
resp.accountId = cardResult.id;
resp.portalRegistered = PortalRegistration.UNREGISTERED; resp.portalRegistered = PortalRegistration.UNREGISTERED;
resp.accountId = maybeCard.id;
} }
return resp; return resp;
@ -263,11 +231,11 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
const req = new FelicaLookupRequestStruct(data); const req = new FelicaLookupRequestStruct(data);
const resp = new FelicaLookupResponseStruct(); const resp = new FelicaLookupResponseStruct();
if (!IsSupportedFelicaMobile(req.osVer)) { if (!IsSupportedFelicaMobile(req.icType)) {
logger.error("Rejecting card because it is not FeliCa Mobile.", { logger.error("Rejecting card because it is not FeliCa Mobile.", {
idm: req.idm, idm: req.idm,
chipCode: req.chipCode, romType: req.romType,
osVer: req.osVer, icType: req.icType,
timing: req.timing, timing: req.timing,
}); });
@ -276,12 +244,11 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
return resp; return resp;
} }
const result = await db const result = await FelicaMobileLookup.findOne({
.select() where: {
.from(felicaMobileLookup) idm: req.idm.toString(16),
.where(eq(felicaMobileLookup.idm, req.idm.toString(16))) },
.limit(1) });
.then((r) => r[0]);
if (result) { if (result) {
header.result = ResultCodes.ID_ALREADY_REGISTERED; header.result = ResultCodes.ID_ALREADY_REGISTERED;
@ -297,13 +264,13 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
{ req } { req }
); );
header.result = ResultCodes.INVALID_AIME_ID; header.result = ResultCodes.UNKNOWN_ERROR;
resp.felicaIndex = -1; resp.felicaIndex = -1;
return resp; return resp;
} }
const row = await createAndInsertNewAccessCode( const row = await generateAndInsertNewAccessCode(
felicaMobileLookup, FelicaMobileLookup,
"FeliCa Mobile", "FeliCa Mobile",
req.idm, req.idm,
"01035", "01035",

View File

@ -11,7 +11,7 @@ import {
FelicaRegisterHandler, FelicaRegisterHandler,
} from "./felica-conversion"; } from "./felica-conversion";
import { ServiceHealthCheckHandler } from "./status-check"; import { ServiceHealthCheckHandler } from "./status-check";
import { CommandId } from "../utils/misc"; import { CommandId } from "../../../lib/constants/aimedb";
import type { AimeDBHandlerFn, AimeDBReturnTypes } from "../types/handlers"; import type { AimeDBHandlerFn, AimeDBReturnTypes } from "../types/handlers";
type CommandIdRequest = { type CommandIdRequest = {

View File

@ -1,5 +1,5 @@
import { PacketHeaderStruct } from "../types/header"; import { PacketHeaderStruct } from "../types/header";
import { CommandId, ResultCodes } from "../utils/misc"; import { CommandId, ResultCodes } from "../../../lib/constants/aimedb";
import type { AimeDBHandlerFn } from "../types/handlers"; import type { AimeDBHandlerFn } from "../types/handlers";
export const ServiceHealthCheckHandler: AimeDBHandlerFn = (header, _) => { export const ServiceHealthCheckHandler: AimeDBHandlerFn = (header, _) => {

View File

@ -1,7 +1,7 @@
import { AIMEDB_HANDLERS, EXPECTED_PACKET_LENGTH } from "./handlers"; import { AIMEDB_HANDLERS, EXPECTED_PACKET_LENGTH } from "./handlers";
import { PacketHeaderStruct } from "./types/header"; import { PacketHeaderStruct } from "./types/header";
import { decryptPacket, encryptPacket } from "./utils/crypto"; import { decryptPacket, encryptPacket } from "./utils/crypto";
import { CommandId, ResultCodes } from "./utils/misc"; import { CommandId, ResultCodes } from "../../lib/constants/aimedb";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config"; import { Config } from "lib/setup/config";
import { createHash } from "crypto"; import { createHash } from "crypto";
@ -124,7 +124,7 @@ const AimeDBServerFactory = () => {
} }
if (header.keychipId === "ABCD1234567" || header.storeId === 0xfff0) { if (header.keychipId === "ABCD1234567" || header.storeId === 0xfff0) {
logger.warning("Received request from uninitialized AMLib.", { logger.warn("Received request from uninitialized AMLib.", {
ip: socket.remoteAddress, ip: socket.remoteAddress,
header, header,
body: packet.slice(0, 32).toString("hex"), body: packet.slice(0, 32).toString("hex"),

View File

@ -24,7 +24,7 @@ export const AimeAccountExtendedResponseStruct = new Struct("AimeAccountExtended
.UInt32LE("accountId") .UInt32LE("accountId")
.UInt8("portalRegistered") .UInt8("portalRegistered")
.UInt8Array("padding", 3) .UInt8Array("padding", 3)
.UInt8Array("authKey", 256) .UInt8Array("segaIdAuthKey", 256)
.UInt32LE("relationId1") .UInt32LE("relationId1")
.UInt32LE("relationId2") .UInt32LE("relationId2")
.compile(); .compile();

View File

@ -2,8 +2,8 @@ import Struct from "typed-struct";
export const FelicaLookupRequestStruct = new Struct("FelicaLookupRequest") export const FelicaLookupRequestStruct = new Struct("FelicaLookupRequest")
.BigUInt64LE("idm") .BigUInt64LE("idm")
.UInt8("chipCode") .UInt8("romType")
.UInt8("osVer") .UInt8("icType")
.UInt8Array("timing", 6) .UInt8Array("timing", 6)
.compile(); .compile();
@ -16,15 +16,15 @@ export const FelicaLookupResponseStruct = new Struct("FelicaLookupResponse")
export const FelicaExtendedLookupRequestStruct = new Struct("FelicaExtendedLookupRequest") export const FelicaExtendedLookupRequestStruct = new Struct("FelicaExtendedLookupRequest")
.UInt8Array("randomChallenge", 16) .UInt8Array("randomChallenge", 16)
.BigUInt64LE("idm") .BigUInt64LE("idm")
.UInt8("chipCode") .UInt8("romType")
.UInt8("osVer") .UInt8("icType")
.UInt8Array("timing", 6) .UInt8Array("timing", 6)
.UInt8Array("cardKeyVersion", 16) .UInt8Array("cardKeyVersion", 16)
.UInt8Array("writeCount", 16) .UInt8Array("writeCount", 16)
.BigUInt64LE("maca") .BigUInt64LE("maca")
.UInt8("companyCode") .UInt8("companyCode")
.UInt8("readerFirmwareVersion") .UInt8("readerFirmwareVersion")
.UInt16LE("DFC") .UInt16LE("dataFormatCode")
.UInt8Array("padding", 4) .UInt8Array("padding", 4)
.compile(); .compile();
@ -36,5 +36,5 @@ export const FelicaExtendedLookupResponseStruct = new Struct("FelicaExtendedLook
.UInt8("portalRegistered") .UInt8("portalRegistered")
.UInt8("companyCode") .UInt8("companyCode")
.UInt8Array("padding", 8) .UInt8Array("padding", 8)
.UInt8Array("authKey", 256) .UInt8Array("segaIdAuthKey", 256)
.compile(); .compile();

View File

@ -0,0 +1,16 @@
import { CalculateAccessCode, CalculateCardKey } from "./access-code";
import t from "tap";
const TEST_CARD_KEY = "5B8CDAEF32960471";
t.test("#CalculateCardKey", (t) => {
t.equal(CalculateCardKey(1, TEST_CARD_KEY), "3653373");
t.end();
});
t.test("#CalculateAccessCode", (t) => {
t.ok(CalculateAccessCode(1, "02019", TEST_CARD_KEY), "02019068568453653373");
t.end();
});

View File

@ -2,19 +2,29 @@ import { Solitaire } from "./crypto";
import { createHash } from "crypto"; import { createHash } from "crypto";
import type { integer } from "types/misc"; import type { integer } from "types/misc";
function reverseString(data: string) { /* eslint-disable no-bitwise */
return Array.from(data).reverse().join(""); export function CalculateCardKey(serial: integer, key: string) {
} const keyBuffer = Buffer.from(key, "hex");
function CalculateCardKey(serial: integer, key: string) {
const paddedSerial = serial.toString().padStart(8, "0"); const paddedSerial = serial.toString().padStart(8, "0");
const realDigest = createHash("md5").update(paddedSerial).digest(); const digest = createHash("md5").update(paddedSerial).digest();
const digest = new Array(16); let hash = 0;
let num = 0;
let storedBits = 0;
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
const idx = Number(`0x${key[i]}`); // Extract the i-th hexadecimal digit of the key...
const nib = realDigest[idx]; const byte = keyBuffer[Math.trunc(i / 2)];
if (byte === undefined) {
throw new Error("Buffer.from returned an undefined value in a Buffer?");
}
// Extract the upper byte and lower byte respectively...
const idx = i % 2 === 0 ? byte >>> 4 : byte & 0xf;
// Using it as the index to shuffle the serial MD5...
const nib = digest[idx];
if (nib === undefined) { if (nib === undefined) {
throw new Error( throw new Error(
@ -22,26 +32,25 @@ function CalculateCardKey(serial: integer, key: string) {
); );
} }
digest[i] = nib; // Store the byte into a temporary little-endian number...
num = num | (nib << storedBits);
storedBits = storedBits + 8;
// XOR every 23 bits or if it's the final iteration
if (storedBits > 23 || i === 15) {
// 0x7fffff is mask to extract the last 23 bits of num.
hash = hash ^ (num & 0x7fffff);
// Removed the bits we worked on
num = num >>> 23;
storedBits = storedBits - 23;
}
} }
// nasty ass bit string hacks that i am not good enough at math to replace return hash.toString().padStart(7, "0");
let bitstring = reverseString(
digest.map((n) => reverseString(n.toString(2).padStart(8, "0"))).join("")
).padStart(6 * 23, "0");
let computed = 0;
while (bitstring) {
const work = Number(`0b${bitstring.slice(0, 23)}`);
// eslint-disable-next-line no-bitwise
computed = computed ^ work;
bitstring = bitstring.slice(23);
}
return computed.toString().padStart(7, "0");
} }
/* eslint-enable no-bitwise */
export function CalculateAccessCode(serial: integer, prefix: string, key: string): string { export function CalculateAccessCode(serial: integer, prefix: string, key: string): string {
const digest = CalculateCardKey(serial, key); const digest = CalculateCardKey(serial, key);

View File

@ -1,20 +1,21 @@
const FELICA_MOBILE_OS_VERSIONS = [0x06, 0x07, 0x10, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18] as const; import { FELICA_CARD_IC_TYPES, FELICA_MOBILE_IC_TYPES } from "lib/constants/felica";
const FELICA_CARD_OS_VERSIONS = [0x20, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7] as const;
export type ValidFeliCaIcTypes =
| typeof FELICA_CARD_IC_TYPES[number]
| typeof FELICA_MOBILE_IC_TYPES[number];
export function IsSupportedFelicaMobile( export function IsSupportedFelicaMobile(
osVer: number icType: number
): osVer is typeof FELICA_MOBILE_OS_VERSIONS[number] { ): icType is typeof FELICA_MOBILE_IC_TYPES[number] {
return (FELICA_MOBILE_OS_VERSIONS as ReadonlyArray<number>).includes(osVer); return (FELICA_MOBILE_IC_TYPES as ReadonlyArray<number>).includes(icType);
} }
export function IsSupportedFelicaCard( export function IsSupportedFelicaCard(
osVer: number icType: number
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] { ): icType is typeof FELICA_CARD_IC_TYPES[number] {
return (FELICA_CARD_OS_VERSIONS as ReadonlyArray<number>).includes(osVer); return (FELICA_CARD_IC_TYPES as ReadonlyArray<number>).includes(icType);
} }
export function IsSupportedFelica( export function IsSupportedFelica(icType: number): icType is ValidFeliCaIcTypes {
osVer: number return IsSupportedFelicaMobile(icType) || IsSupportedFelicaCard(icType);
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] | typeof FELICA_MOBILE_OS_VERSIONS[number] {
return IsSupportedFelicaMobile(osVer) || IsSupportedFelicaCard(osVer);
} }

View File

@ -4,12 +4,11 @@
import express from "express"; import express from "express";
import "express-async-errors"; import "express-async-errors";
// eslint-disable-next-line import/order
import mainRouter from "./router/router"; import mainRouter from "./router/router";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import { DFIRequestResponse } from "./middleware/dfi"; import { DFIRequestResponse } from "./middleware/dfi";
import type { Express } from "express"; import type { Express } from "express";
import { RequestLoggerMiddleware } from "./middleware/request-logger"; import { RequestLoggerMiddleware } from "../../lib/middleware/request-logger";
import { IsRecord } from "utils/misc"; import { IsRecord } from "utils/misc";
const logger = CreateLogCtx(__filename); const logger = CreateLogCtx(__filename);
@ -30,10 +29,16 @@ process.on("unhandledRejection", (reason, promise) => {
logger.error(reason, { promise }); logger.error(reason, { promise });
}); });
// ALL.Net sends a form body that is sometimes deflated and base64-encoded. // We do the body handling here so logging is properly handled.
// This also does some special handling to prevent keys/values from being app.use((req, res, next) => {
// URL-escaped, which ALL.Net doesn't do. Pray they don't need an & or a =. if (req.path === "/sys/servlet/PowerOn") {
app.use(DFIRequestResponse); DFIRequestResponse(true)(req, res, next);
} else if (req.path === "/report-api/Report") {
express.json({ limit: "4mb", type: (_) => true })(req, res, next);
} else {
DFIRequestResponse(false)(req, res, next);
}
});
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) { if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
@ -50,6 +55,12 @@ app.use(RequestLoggerMiddleware);
app.use("/", mainRouter); app.use("/", mainRouter);
const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => { const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => {
// eslint-disable-next-line cadence/no-instanceof
if (err instanceof SyntaxError && req.path === "/report-api/Report") {
logger.error("Could not parse download report as JSON.", { err });
return res.status(200).send("NG");
}
logger.info(`MAIN_ERR_HANDLER hit by request.`, { url: req.originalUrl }); logger.info(`MAIN_ERR_HANDLER hit by request.`, { url: req.originalUrl });
const unknownErr = err as unknown; const unknownErr = err as unknown;

View File

@ -6,59 +6,61 @@ import type { RequestHandler } from "express";
const inflateAsync = promisify(inflate); const inflateAsync = promisify(inflate);
export const DFIRequestResponse: RequestHandler = async (req, res, next) => { export const DFIRequestResponse: (mustUseDfi: boolean) => RequestHandler = (mustUseDfi) => {
if (Number(req.headers["content-length"] ?? 0) === 0) { return async (req, res, next) => {
if (Number(req.headers["content-length"] ?? 0) === 0) {
next();
return;
}
const isUsingDfi = req.headers.pragma?.toUpperCase() === "DFI";
if (mustUseDfi && !isUsingDfi) {
return res.status(200).send("");
}
const rawBody = await getRawBody(req, { encoding: "utf-8" });
let body: string;
if (isUsingDfi) {
const compressedBuffer = Buffer.from(rawBody, "base64");
const buffer = await inflateAsync(compressedBuffer);
body = buffer.toString("utf-8").trim();
} else {
body = rawBody.trim();
}
// Keys and values are not URL escaped.
// This should be fine. I think.
// eslint-disable-next-line require-atomic-updates
req.body = Object.fromEntries(body.split("&").map((s) => s.split("=")));
req.safeBody = req.body as Record<string, unknown>;
const originalSend = res.send;
res.send = (params) => {
const body = `${new URLSearchParams(params).toString()}\n`;
const encoding = req.body.encode ?? "EUC-JP";
const encodedBody = iconv.encode(body, encoding);
res.header("Content-Type", `text/plain; charset=${encoding}`);
// TODO: I don't know what black magic SEGA did, but I have been woefully
// unable to DFI-encode my responses...
return originalSend.apply(res, [encodedBody]);
// if (req.headers.pragma?.toUpperCase() !== "DFI") {
// return originalSend.apply(res, [encodedBody]);
// }
// res.header("Pragma", "DFI");
// return originalSend.apply(res, [deflateSync(encodedBody).toString("base64")]);
};
next(); next();
return;
}
const isUsingDfi = req.headers.pragma?.toUpperCase() === "DFI";
const rawBody = await getRawBody(req, { encoding: "utf-8" });
let body: string;
if (isUsingDfi) {
const compressedBuffer = Buffer.from(rawBody, "base64");
const buffer = await inflateAsync(compressedBuffer);
body = buffer.toString("utf-8").trim();
} else {
body = rawBody.trim();
}
// Keys and values are not URL escaped.
// This should be fine. I think.
// eslint-disable-next-line require-atomic-updates
req.body = Object.fromEntries(body.split("&").map((s) => s.split("=")));
const originalSend = res.send;
res.send = (params) => {
const body =
typeof params === "object"
? `${Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join("&")}\n`
: params;
const encoding = req.body.encode ?? "EUC-JP";
const encodedBody = iconv.encode(body, encoding);
res.header("Content-Type", `text/plain; charset=${encoding}`);
// TODO: I don't know what black magic SEGA did, but I have been woefully
// unable to DFI-encode my responses...
return originalSend.apply(res, [encodedBody]);
// if (req.headers.pragma?.toUpperCase() !== "DFI") {
// return originalSend.apply(res, [encodedBody]);
// }
// res.header("Pragma", "DFI");
// return originalSend.apply(res, [deflateSync(encodedBody).toString("base64")]);
}; };
next();
}; };

View File

@ -0,0 +1,64 @@
import { Router } from "express";
import { Machine } from "external/db/entity/allnet";
import { EventLog } from "external/db/entity/base";
import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import { DownloadReportSchema } from "servers/allnet/types/download-report";
import { fromZodError } from "zod-validation-error";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true });
router.post("/", async (req, res) => {
const parseResult = DownloadReportSchema.safeParse(req.safeBody);
if (!parseResult.success) {
logger.error("Invalid download progress report.", {
err: fromZodError(parseResult.error).message,
});
return res.status(200).send("NG");
}
const reportData = parseResult.data.appimage ?? parseResult.data.optimage;
if (!reportData) {
logger.error(
"Invalid download progress report. Neither appimage nor optimage was available."
);
return res.status(200).send("NG");
}
if (!Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
const machine = await Machine.findOne({
where: {
serial: reportData.serial,
},
});
if (!machine) {
logger.error("Rejected download progress report from unknown serial.", { reportData });
return res.status(200).send("NG");
}
}
const message = `${reportData.serial} reported download state for ${reportData.gd} v${
reportData.dav
}: ${reportData.tdsc}/${reportData.tsc} segments downloaded for working files ${
reportData.wfl
} with ${reportData.dfl ?? "none"} complete.`;
const eventLog = EventLog.construct({
system: "allnet",
type: "DOWNLOAD_REPORT",
severity: "info",
message,
details: reportData,
});
await eventLog.save();
return res.status(200).send("OK");
});
export default router;

View File

@ -0,0 +1,8 @@
import reportRouter from "./Report/router";
import { Router } from "express";
const router: Router = Router({ mergeParams: true });
router.use("/Report", reportRouter);
export default router;

View File

@ -1,3 +1,4 @@
import reportRouter from "./report-api/router";
import sysServletRouter from "./sys/servlet/router"; import sysServletRouter from "./sys/servlet/router";
import { Router } from "express"; import { Router } from "express";
@ -8,5 +9,6 @@ router.all("/naomitest.html", (_, res) => {
}); });
router.use("/sys/servlet", sysServletRouter); router.use("/sys/servlet", sysServletRouter);
router.use("/report-api", reportRouter);
export default router; export default router;

View File

@ -1,19 +1,44 @@
import { Router } from "express"; import { Router } from "express";
import { Machine } from "external/db/entity/allnet";
import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import { import {
DownloadOrderRequestSchema, DownloadOrderRequestSchema,
DownloadOrderStatus, DownloadOrderStatus,
} from "servers/allnet/types/download-order"; } from "servers/allnet/types/download-order";
import { fromZodError } from "zod-validation-error";
import type { DownloadOrderResponse } from "servers/allnet/types/download-order"; import type { DownloadOrderResponse } from "servers/allnet/types/download-order";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true }); const router: Router = Router({ mergeParams: true });
router.post("/", (req, res) => { router.post("/", async (req, res) => {
const parseResult = DownloadOrderRequestSchema.safeParse(req.safeBody); const parseResult = DownloadOrderRequestSchema.safeParse(req.safeBody);
if (!parseResult.success) { if (!parseResult.success) {
logger.error("Received invalid download order request.", {
error: fromZodError(parseResult.error).message,
body: req.safeBody,
});
return res.status(403).send(""); return res.status(403).send("");
} }
const data = parseResult.data;
if (!Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
const machine = await Machine.findOne({
where: {
serial: data.serial,
},
});
if (!machine) {
logger.error("Rejected download order request from unknown serial.", { data });
return res.status(200).send({ stat: 1, serial: "", uri: "null" });
}
}
// TODO: Allow network delivery. // TODO: Allow network delivery.
const response = { const response = {
stat: DownloadOrderStatus.FAILURE, stat: DownloadOrderStatus.FAILURE,

View File

@ -1,7 +1,5 @@
import { eq } from "drizzle-orm";
import { Router } from "express"; import { Router } from "express";
import { db } from "external/db/db"; import { Machine } from "external/db/entity/allnet";
import { arcade, machine } from "external/db/schemas";
import CreateLogCtx from "lib/logger/logger"; import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config"; import { Config } from "lib/setup/config";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
@ -33,11 +31,10 @@ router.post("/", async (req, res) => {
return res.status(500).send(); return res.status(500).send();
} }
const formatVer = req.safeBody.format_ver ?? "1"; const formatVer = (req.safeBody.format_ver as string | undefined) ?? "1";
let parseResult; let parseResult;
// TODO: ALL.Net China
if (formatVer === "1") { if (formatVer === "1") {
parseResult = PowerOnRequestV1Schema.safeParse(req.safeBody); parseResult = PowerOnRequestV1Schema.safeParse(req.safeBody);
} else if (formatVer === "2") { } else if (formatVer === "2") {
@ -57,8 +54,10 @@ router.post("/", async (req, res) => {
} }
if (!parseResult.success) { if (!parseResult.success) {
logger.error(`Received invalid PowerOn request: ${fromZodError(parseResult.error)}`, { logger.error(`Received invalid PowerOn request.`, {
err: fromZodError(parseResult.error).message,
formatVer, formatVer,
body: req.safeBody,
}); });
return res.status(400).send(""); return res.status(400).send("");
} }
@ -96,14 +95,13 @@ router.post("/", async (req, res) => {
// TODO: Implement store authentication + fetch arcade information // TODO: Implement store authentication + fetch arcade information
// Reference implementation: https://sega.bsnk.me/allnet/auth/power-on // Reference implementation: https://sega.bsnk.me/allnet/auth/power-on
// For now, we just check if there's a cab registered in the database. // For now, we just check if there's a cab registered in the database.
const cabAndStore = await db const result = await Machine.findOne({
.select() where: {
.from(machine) serial,
.leftJoin(arcade, eq(arcade.id, machine.arcade_id)) },
.where(eq(machine.serial, serial)) });
.then((r) => r[0]);
if (!cabAndStore && !Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) { if (!result && !Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
logger.error("Unregistered serial attempted ALL.Net authentication.", { logger.error("Unregistered serial attempted ALL.Net authentication.", {
gameId, gameId,
serial, serial,
@ -115,21 +113,19 @@ router.post("/", async (req, res) => {
// TODO: Verify that title exists and is enabled. // TODO: Verify that title exists and is enabled.
const serverTime = DateTime.now().setZone("Asia/Tokyo"); const serverTime = DateTime.now().setZone("Asia/Tokyo");
const baseResponse = { const baseResponse = {
// Same thing
stat: 1, stat: 1,
place_id: (result?.arcade.id ?? 0x123).toString(16).toUpperCase(),
place_id: (0x123).toString(16),
// uri: `http://localhost:8080/${gameId}/${titleVer.replace(/\./u, "")}/`, // uri: `http://localhost:8080/${gameId}/${titleVer.replace(/\./u, "")}/`,
uri: `http://localhost:8080/${titleVer.replace(/\./u, "")}/`, uri: `http://localhost:8080/${titleVer.replace(/\./u, "")}/`,
host: "localhost:8080", host: "localhost:8080",
name: cabAndStore?.allnet_arcade?.name ?? Config.NAME, name: result?.arcade.name ?? Config.NAME,
nickname: cabAndStore?.allnet_arcade?.nickname ?? "kozukata-toa", nickname: result?.arcade.nickname ?? "kozukatatoa",
setting: 1, setting: 1,
region0: cabAndStore?.allnet_arcade?.regionId ?? 1, region0: result?.arcade.regionId ?? 1,
region_name0: cabAndStore?.allnet_arcade?.regionName0 ?? "W", region_name0: result?.arcade.regionName0 ?? "W",
region_name1: cabAndStore?.allnet_arcade?.regionName1 ?? "", region_name1: result?.arcade.regionName1 ?? "",
region_name2: cabAndStore?.allnet_arcade?.regionName2 ?? "", region_name2: result?.arcade.regionName2 ?? "",
region_name3: cabAndStore?.allnet_arcade?.regionName3 ?? "", region_name3: result?.arcade.regionName3 ?? "",
}; };
let response; let response;
@ -164,7 +160,7 @@ router.post("/", async (req, res) => {
allnet_id: 456, allnet_id: 456,
utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"), utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
country: "JPN", country: "JPN",
client_timezone: "+0900", client_timezone: " 0900",
token: request.token ?? "null", token: request.token ?? "null",
} satisfies PowerOnResponseV3; } satisfies PowerOnResponseV3;
} else if (request.format_ver === "3" && "game_ver" in request) { } else if (request.format_ver === "3" && "game_ver" in request) {

View File

@ -0,0 +1,35 @@
import { z } from "zod";
const zodOptionalInteger = z.number().int().optional();
export const DownloadOptImageReportSchema = z.object({
serial: z.string().min(1).max(11),
dfl: z.array(z.string().min(1)).optional(),
wfl: z.array(z.string().min(1)).optional(),
tsc: z.number().int(),
tdsc: z.number().int(),
at: zodOptionalInteger,
ot: zodOptionalInteger,
rt: zodOptionalInteger,
as: z.number().int().gte(1).lte(2),
rf_state: zodOptionalInteger,
gd: z.string(),
dav: z.string(),
dov: z.string(),
});
export const DownloadAppImageReportSchema = DownloadOptImageReportSchema.extend({
wdav: z.string(),
wdov: z.string(),
});
export const DownloadReportSchema = z
.object({
appimage: DownloadAppImageReportSchema.optional(),
optimage: DownloadOptImageReportSchema.optional(),
})
// One of these must be present
.refine(
(arg) => !!arg.appimage || !!arg.optimage,
"At least one of 'appimage' or 'optimage' must be available"
);

View File

@ -1,16 +1,18 @@
import { z } from "zod"; import { z } from "zod";
import type { integer } from "types/misc"; import type { integer } from "types/misc";
const zodCoerceOptionalFiveDigitInteger = z.coerce.number().int().gte(-99999).lte(99999).optional();
export const PowerOnRequestV1Schema = z.object({ export const PowerOnRequestV1Schema = z.object({
game_id: z.string().max(5), game_id: z.string().max(5),
ver: z.string().max(5), ver: z.string().max(5),
serial: z.string().max(11), serial: z.string().max(11),
ip: z.string().ip(), ip: z.string().ip(),
firm_ver: z.coerce.number().optional(), firm_ver: zodCoerceOptionalFiveDigitInteger,
boot_ver: z.coerce.number().optional(), boot_ver: zodCoerceOptionalFiveDigitInteger,
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"), encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
format_ver: z.literal("1"), format_ver: z.literal("1").optional(),
hops: z.coerce.number().default(-1), hops: z.coerce.number().int().gte(-99999).lte(99999).default(-1),
}); });
export type PowerOnRequestV1 = z.infer<typeof PowerOnRequestV1Schema>; export type PowerOnRequestV1 = z.infer<typeof PowerOnRequestV1Schema>;

View File

@ -1,2 +1,3 @@
export { default as allnetServer } from "./allnet"; export { default as allnetServer } from "./allnet";
export { default as aimeDbServerFactory } from "./aimedb"; export { default as aimeDbServerFactory } from "./aimedb";
export { default as titleServer } from "./titles";

View File

@ -0,0 +1,218 @@
import { VERSIONS } from "./versions";
import { GetClassMethods } from "../utils/reflection";
import compression from "compression";
import { Router, json } from "express";
import { ChunithmVersions } from "lib/constants/game-versions";
import CreateLogCtx from "lib/logger/logger";
import { RequestLoggerMiddleware } from "lib/middleware/request-logger";
import { Config, Environment } from "lib/setup/config";
import getRawBody from "raw-body";
import { createDecipheriv, pbkdf2Sync } from "crypto";
import type { RequestHandler } from "express";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: false });
router.use(compression({ threshold: 0 }));
router.use(async (req, res, next) => {
if ((req.headers["content-length"] ?? 0) === 0) {
next();
return;
}
// /SDHD/210/UpsertUserAllApi
const splitPath = req.path.split("/");
const version = splitPath[2];
const versionNum = Number(version);
const endpoint = splitPath.at(-1);
if (!endpoint || !version || Number.isNaN(versionNum)) {
next();
return;
}
const isEncryptedRequest =
endpoint.length === 32 && [...endpoint].every((c) => "0123456789abcdefABCDEF".includes(c));
if (
!isEncryptedRequest &&
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
versionNum >= ChunithmVersions.CRYSTAL_PLUS
) {
logger.error(
"Rejecting unencrypted request, because the server is configured to accept only encrypted requests.",
{ version, endpoint }
);
return res.status(200).send({ stat: "0" });
}
if (!isEncryptedRequest) {
next();
return;
}
logger.debug("Received encrypted request.", { version, endpoint });
const keys = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[version];
if (!keys) {
logger.error("No keys were configured for the requested version.", {
version,
endpoint,
});
return res.status(200).send({ stat: "0" });
}
// Mostly as a convenience, so versions PARADISE LOST and older don't have to provide
// the path salt.
const userAgent = req.headers["user-agent"];
if (versionNum <= ChunithmVersions.PARADISE && userAgent) {
const unhashedEndpoint = userAgent.split("#")[0];
if (unhashedEndpoint) {
req.url = req.url.replace(endpoint, unhashedEndpoint);
} else {
logger.warn(
"User-Agent did not contain the unhashed endpoint for version PARADISE (LOST) or older?",
{ version, userAgent }
);
return res.status(200).send({ stat: "0" });
}
}
const key = keys[0];
const iv = keys[1];
if (!key || !iv) {
logger.error("Key or IV was not provided for the requested version.", {
version,
key,
iv,
});
return res.status(200).send({ stat: "0" });
}
const cipher = createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), Buffer.from(iv, "hex"));
const body = await getRawBody(req);
// eslint-disable-next-line require-atomic-updates
req.body = Buffer.concat([cipher.update(body), cipher.final()]);
// TODO: Encrypt response
next();
});
router.use((req, res, next) => {
if (Environment.nodeEnv === "dev" && req.header("x-debug") !== undefined) {
next();
return;
}
// Get express.json() to recognize
req.headers["content-encoding"] = "deflate";
next();
});
// All requests to the title server will be JSON, I promise!
router.use(json({ type: (_) => true }));
router.use((req, res, next) => {
// Always mount an empty req body. We operate under the assumption that req.body is
// always defined as atleast an object.
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
req.body = {};
}
// req.safeBody *is* just a type-safe req.body!
req.safeBody = req.body as Record<string, unknown>;
next();
});
router.use(RequestLoggerMiddleware);
for (const Title of VERSIONS) {
const inst = new Title();
const endpointSaltString = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[2];
const iterations = inst.numericVersion >= ChunithmVersions.SUN ? 70 : 44;
const key = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[0];
const iv = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[1];
if (
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
inst.numericVersion >= ChunithmVersions.CRYSTAL_PLUS &&
(!key || !iv)
) {
logger.warn(
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no keys are provided.`,
{
gameCode: inst.gameCode,
version: inst.version,
}
);
continue;
}
if (
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
inst.numericVersion >= ChunithmVersions.NEW &&
!endpointSaltString
) {
logger.warn(
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no endpoint salt was provided.`,
{
gameCode: inst.gameCode,
version: inst.version,
}
);
}
const subrouter: Router = Router({ mergeParams: false });
const methods = GetClassMethods(Title).filter((m) => m.startsWith("handle_"));
const endpointSalt = endpointSaltString ? Buffer.from(endpointSaltString, "hex") : null;
for (const method of methods) {
const methodName = method.split("_")[1];
if (!methodName) {
logger.warn(
"Method starts with 'handle_', but cannot get the part after the underscore?",
{
method,
}
);
continue;
}
const impl = (inst as unknown as Record<string, unknown>)[method] as RequestHandler;
const implWrapper: RequestHandler = (req, res, next) => {
impl.apply(inst, [req, res, next]);
};
subrouter.post(`/${methodName}`, implWrapper);
if (endpointSalt) {
const hash = pbkdf2Sync(methodName, endpointSalt, iterations, 16, "sha1").toString(
"hex"
);
subrouter.post(`/${hash}`, implWrapper);
}
}
router.use(`/${inst.gameCode}/${inst.version}/${inst.servletName}`, subrouter);
// TODO: Add to a title registry
}
export default router;

View File

@ -0,0 +1,197 @@
import { ChunithmStaticCharge, ChunithmStaticEvent } from "external/db/entity/chunithm/static";
import CreateLogCtx from "lib/logger/logger";
import { DateTime } from "luxon";
import { BaseTitle } from "servers/titles/types/titles";
import type { Request, Response } from "express";
import type { ITitle } from "servers/titles/types/titles";
const logger = CreateLogCtx(__filename);
export class Chunithm extends BaseTitle implements ITitle {
private readonly dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "100", servletName ?? "ChuniServlet");
}
protected createGetGameSettingsApiResponse(_req: Request, _res: Response) {
// HACK: This is not the way to go if you're somehow hosting an arcade server
// from my code, and wants your machines to actually restart sometimes.
const rebootStartTime = DateTime.now().minus({ hours: 4 }).toFormat(this.dateTimeFormat);
const rebootEndTime = DateTime.now().minus({ hours: 3 }).toFormat(this.dateTimeFormat);
return {
gameSetting: {
dataVersion: "1.00.00",
isMaintenance: "false",
requestInterval: "10",
rebootStartTime,
rebootEndTime,
isBackgroundDistribute: "false",
maxCountCharacter: "300",
maxCountItem: "300",
maxCountMusic: "300",
},
isDumpUpload: "false",
isAou: "false",
};
}
/**
* @note override {@link createGetGameSettingsApiResponse} in order to modify this
* method's response
* @since CHUNITHM
*/
handle_GetGameSettingsApi(req: Request, res: Response) {
const resp = this.createGetGameSettingsApiResponse(req, res);
return res.status(200).send(resp);
}
handle_UpsertClientSettingApi(req: Request, res: Response) {
return res.status(200).send({ returnCode: "1" });
}
handle_UpsertClientTestmodeApi(req: Request, res: Response) {
return res.status(200).send({ returnCode: "1" });
}
handle_GetGameIdlistApi(req: Request, res: Response) {
return res.status(200).send({ type: req.safeBody.type, length: "0", gameIdlistList: [] });
}
async handle_GetGameEventApi(req: Request, res: Response) {
const events = await ChunithmStaticEvent.find({
where: {
enabled: true,
version: this.numericVersion,
},
});
if (events.length === 0) {
logger.warn("No events are enabled.", {
gameCode: this.gameCode,
version: this.version,
});
}
// HACK: We may want to rotate events...
return res.send({
type: req.safeBody.type,
length: events.length.toString(),
gameEventList: events.map((e) => ({
id: e.eventId.toString(),
type: e.type.toString(),
startDate: DateTime.fromJSDate(e.startDate).toFormat(this.dateTimeFormat),
endDate: "2099-12-31 00:00:00",
})),
});
}
handle_GetGameRankingApi(req: Request, res: Response) {
// TODO
return res.send({
type: req.safeBody.type,
length: "0",
gameRankingList: [],
});
}
async handle_GetGameChargeApi(req: Request, res: Response) {
const charges = await ChunithmStaticCharge.find({
where: {
enabled: true,
version: this.numericVersion,
},
});
if (charges.length === 0) {
logger.warn("No charges (tickets) are enabled.", {
gameCode: this.gameCode,
version: this.version,
});
}
// HACK
return res.send({
length: charges.length.toString(),
gameChargeList: charges.map((c, i) => ({
orderId: i.toString(),
chargeId: c.chargeId.toString(),
price: "1",
startDate: "2017-12-05 07:00:00.0",
endDate: "2099-12-31 00:00:00.0",
salePrice: "1",
saleStartDate: "2017-12-05 07:00:00.0",
saleEndDate: "2099-12-31 00:00:00.0",
})),
});
}
handle_GetUserPreviewApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GameLoginApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserDataApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserOptionApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserCharacterApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserActivityApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserItemApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserRecentRatingApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserMusicApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserRegionApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserFavoriteItemApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserLoginBonusApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserMapAreaApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GetUserSymbolChatSettingApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
/**
* @since NEW
*/
handle_GetUserNetBattleDataApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
handle_GameLogoutApi(req: Request, res: Response) {
return res.status(200).send({ returnCode: "1" });
}
}

View File

@ -0,0 +1,23 @@
import { Chunithm } from "./100-base";
import type { Request, Response } from "express";
export class ChunithmAirPlus extends Chunithm {
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "125", servletName ?? "ChuniServlet");
}
override createGetGameSettingsApiResponse(req: Request, res: Response) {
const resp = super.createGetGameSettingsApiResponse(req, res);
resp.gameSetting.dataVersion = "1.25.00";
return resp;
}
/**
* @since AIR PLUS
*/
handle_GetUserCourseApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
}

View File

@ -0,0 +1,23 @@
import { ChunithmAirPlus } from "./125-airplus";
import type { Request, Response } from "express";
export class ChunithmAmazon extends ChunithmAirPlus {
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "130", servletName ?? "ChuniServlet");
}
override createGetGameSettingsApiResponse(req: Request, res: Response) {
const resp = super.createGetGameSettingsApiResponse(req, res);
resp.gameSetting.dataVersion = "1.30.00";
return resp;
}
/**
* @since AMAZON
*/
handle_GetUserDuelApi(req: Request, res: Response) {
throw new Error("Unimplemented");
}
}

View File

@ -0,0 +1,29 @@
import { ChunithmAmazon } from "./130-amazon";
import type { Request, Response } from "express";
export class ChunithmAmazonPlus extends ChunithmAmazon {
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "135", servletName ?? "ChuniServlet");
}
override createGetGameSettingsApiResponse(req: Request, res: Response) {
const resp = super.createGetGameSettingsApiResponse(req, res);
resp.gameSetting.dataVersion = "1.35.00";
return resp;
}
/**
* @since AMAZON PLUS
*/
handle_GetUserFavoriteMusicApi(req: Request, res: Response) {
// TODO: Add a web UI for favorites
return res.send({
userId: req.safeBody.userId,
length: "0",
userFavoriteMusicList: [],
});
}
}

View File

@ -0,0 +1,72 @@
import { ChunithmAmazonPlus } from "./135-amazonplus";
import { Config } from "lib/setup/config";
import type { Request, Response } from "express";
export class ChunithmCrystal extends ChunithmAmazonPlus {
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "140", servletName ?? "ChuniServlet");
}
override createGetGameSettingsApiResponse(req: Request, res: Response) {
const resp = super.createGetGameSettingsApiResponse(req, res);
resp.gameSetting.dataVersion = "1.40.00";
return resp;
}
/**
* @since CRYSTAL
*/
handle_GetUserTeamApi(req: Request, res: Response) {
// TODO
const teamName = Config.CHUNITHM_CONFIG.MODS.TEAM_NAME;
if (!teamName) {
return res.send({
userId: req.safeBody.userId,
teamId: "0",
});
}
return res.send({
userId: req.safeBody.userId,
teamId: "1",
teamRank: "1",
teamName,
userTeamPoint: {
userId: req.safeBody.userId,
teamId: 1,
orderId: 1,
teamPoint: 1,
aggrDate: req.safeBody.playDate,
},
});
}
/**
* @since CRYSTAL
*/
handle_GetTeamCourseSettingApi(req: Request, res: Response) {
// TODO
return res.send({
userId: req.safeBody.userId,
length: "0",
nextIndex: "0",
teamCourseSettingList: [],
});
}
/**
* @since CRYSTAL
*/
handle_GetTeamCourseRuleApi(req: Request, res: Response) {
// TODO
return res.send({
userId: req.safeBody.userId,
length: "0",
nextIndex: "0",
teamCourseRuleList: [],
});
}
}

View File

@ -0,0 +1,14 @@
import { Chunithm } from "./100-base";
import { ChunithmAirPlus } from "./125-airplus";
import { ChunithmAmazon } from "./130-amazon";
import { ChunithmAmazonPlus } from "./135-amazonplus";
import { ChunithmCrystal } from "./140-crystal";
import type { BaseTitle } from "servers/titles/types/titles";
export const VERSIONS: Array<typeof BaseTitle> = [
Chunithm,
ChunithmAirPlus,
ChunithmAmazon,
ChunithmAmazonPlus,
ChunithmCrystal,
];

View File

@ -0,0 +1,31 @@
// THIS IMPORT **MUST** GO HERE. DO NOT MOVE IT. IT MUST OCCUR BEFORE ANYTHING HAPPENS WITH EXPRESS
// BUT AFTER EXPRESS IS IMPORTED.
/* eslint-disable import/order */
import express from "express";
import "express-async-errors";
import chunithmRouter from "./chunithm";
import CreateLogCtx from "lib/logger/logger";
import type { Express } from "express";
const logger = CreateLogCtx(__filename);
const app: Express = express();
// Pass the IP of the user up our increasingly insane chain of nginx/docker nonsense
app.set("trust proxy", ["loopback", "linklocal", "uniquelocal"]);
// we don't allow nesting in query strings.
app.set("query parser", "simple");
// taken from https://nodejs.org/api/process.html#process_event_unhandledrejection
// to avoid future deprecation.
process.on("unhandledRejection", (reason, promise) => {
// @ts-expect-error reason is an error, and the logger can handle errors
// it just refuses.
logger.error(reason, { promise });
});
app.use("/", chunithmRouter);
export default app;

View File

@ -0,0 +1,20 @@
export interface ITitle {
gameCode: string;
version: string;
servletName: string;
}
export class BaseTitle implements ITitle {
gameCode: string;
version: string;
servletName: string;
numericVersion: number;
constructor(gameCode?: string, version?: string, servletName?: string) {
this.gameCode = gameCode ?? "";
this.version = version?.replace(/\./gu, "") ?? "";
this.servletName = servletName ?? "";
this.numericVersion = Number(this.version);
}
}

View File

@ -0,0 +1,41 @@
/* eslint-disable cadence/no-instanceof */
function IsRecord<T = string>(v: unknown): v is Record<string, T> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
export function GetClassMethods(className: unknown): Array<string> {
if (typeof className !== "function" || !("prototype" in className)) {
throw new Error("Not a class");
}
if (!IsRecord(className.prototype)) {
throw new Error("Not a class");
}
const ret = new Set<string>();
function methods(obj: Record<number | string | symbol, unknown> | null | undefined) {
if (obj === null || obj === undefined) {
return;
}
const ps = Object.getOwnPropertyNames(obj);
for (const p of ps) {
try {
if (obj[p] instanceof Function) {
ret.add(p);
}
} catch {
continue;
}
}
methods(Object.getPrototypeOf(obj));
}
methods(className.prototype);
return Array.from(ret);
}

View File

@ -15,8 +15,8 @@
"target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ "lib": ["ES2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */ // "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
@ -85,7 +85,7 @@
/* Type Checking */ /* Type Checking */
"strict": true, /* Enable all strict type-checking options. */ "strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */