push: rolling in hacked up title server
This commit is contained in:
parent
c22cc400d5
commit
ddadb82f2f
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
js
|
||||
package.json
|
||||
*.config.*
|
||||
*.config.*
|
||||
plugins
|
10
package.json
10
package.json
|
@ -12,6 +12,7 @@
|
|||
"typecheck": "tsc --project tsconfig.build.json --noEmit",
|
||||
"start": "pnpm build && pnpm start-no-build",
|
||||
"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"
|
||||
},
|
||||
"author": "beerpsi",
|
||||
|
@ -21,9 +22,8 @@
|
|||
"pnpm": "7"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.1.1",
|
||||
"compression": "^1.7.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"drizzle-orm": "^0.29.0",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"fletcher": "^0.0.3",
|
||||
|
@ -32,9 +32,12 @@
|
|||
"luxon": "^3.4.4",
|
||||
"micro-packed": "^0.3.2",
|
||||
"raw-body": "^2.5.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"safe-json-stringify": "^1.2.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typed-struct": "^2.3.0",
|
||||
"typeorm": "^0.3.17",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"zod": "^3.22.4",
|
||||
|
@ -42,6 +45,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.7",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/iconv": "^3.0.4",
|
||||
"@types/luxon": "^3.3.4",
|
||||
|
@ -49,9 +53,9 @@
|
|||
"@types/safe-json-stringify": "^1.1.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.47.1",
|
||||
"@typescript-eslint/parser": "5.47.1",
|
||||
"drizzle-kit": "^0.20.4",
|
||||
"eslint": "8.18.0",
|
||||
"eslint-plugin-cadence": "^0.1.0",
|
||||
"tap": "^18.6.1",
|
||||
"typescript": "4.9.4"
|
||||
}
|
||||
}
|
||||
|
|
3619
pnpm-lock.yaml
3619
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -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,
|
||||
});
|
|
@ -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" });
|
|
@ -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`);
|
|
@ -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`);
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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),
|
||||
})
|
||||
);
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./aimedb";
|
||||
export * from "./allnet";
|
||||
export * from "./base";
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -63,6 +63,8 @@ export const enum PortalRegistration {
|
|||
}
|
||||
|
||||
export const enum CompanyCodes {
|
||||
/** Not really, could be FeliCa Mobile. */
|
||||
INVALID = 0,
|
||||
SEGA = 1,
|
||||
BANDAI_NAMCO = 2,
|
||||
KONAMI = 3,
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
}
|
|
@ -24,34 +24,89 @@ const config: unknown = JSON5.parse(configFile);
|
|||
|
||||
const zod16bitNumber = z.number().gt(0).lte(65535);
|
||||
const zodLogLevel = z.enum(["crit", "debug", "error", "info", "verbose", "warn"]);
|
||||
const zodOptionalHexString16 = z
|
||||
.string()
|
||||
.length(16)
|
||||
.regex(/^[0-9a-z]+$/iu, "value is not a hex string")
|
||||
.optional();
|
||||
const zodHexString = z.string().regex(/^[0-9a-z]+$/iu, "value is not a hex string");
|
||||
const zodHexString16 = zodHexString.length(16);
|
||||
|
||||
const configSchema = z.object({
|
||||
NAME: z.string(),
|
||||
DATABASE_PATH: z.string(),
|
||||
LISTEN_ADDRESS: z.string().ip(),
|
||||
NAME: z.string().default("Kozukata Toa"),
|
||||
DATABASE_PATH: z.string().default("data/db.sqlite3"),
|
||||
LISTEN_ADDRESS: z.string().ip().default("0.0.0.0"),
|
||||
LOGGER_CONFIG: z.object({
|
||||
LOG_LEVEL: zodLogLevel,
|
||||
CONSOLE: z.boolean(),
|
||||
FOLDER: z.string().optional(),
|
||||
LOG_LEVEL: zodLogLevel.default("info"),
|
||||
CONSOLE: z.boolean().default(true),
|
||||
FOLDER: z.string().default("logs"),
|
||||
}),
|
||||
ALLNET_CONFIG: z.object({
|
||||
ENABLED: z.boolean(),
|
||||
PORT: zod16bitNumber,
|
||||
ALLOW_UNREGISTERED_SERIALS: z.boolean(),
|
||||
ENABLED: z.boolean().default(true),
|
||||
PORT: zod16bitNumber.default(80),
|
||||
ALLOW_UNREGISTERED_SERIALS: z.boolean().default(true),
|
||||
UPDATE_CFG_FOLDER: z.string().optional(),
|
||||
}),
|
||||
AIMEDB_CONFIG: z.object({
|
||||
ENABLED: z.boolean(),
|
||||
PORT: zod16bitNumber,
|
||||
KEY: z.string().length(16).optional(),
|
||||
AIME_MOBILE_CARD_KEY: zodOptionalHexString16,
|
||||
RESERVED_CARD_PREFIX: z.string().length(5).optional(),
|
||||
RESERVED_CARD_KEY: zodOptionalHexString16,
|
||||
ENABLED: z.boolean().default(true),
|
||||
PORT: zod16bitNumber.default(22345),
|
||||
KEY: z.string().length(16).default("Copyright(C)SEGA"),
|
||||
AIME_MOBILE_CARD_KEY: zodHexString16.default("5CD3E81B9024F67A"),
|
||||
RESERVED_CARD_PREFIX: z.string().length(5).default("01053"),
|
||||
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;
|
||||
|
||||
// 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",
|
||||
};
|
||||
|
|
38
src/main.ts
38
src/main.ts
|
@ -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 { Config } from "lib/setup/config";
|
||||
import net from "net";
|
||||
import "reflect-metadata";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
logger.info(`Booting ${Config.NAME}.`, { bootInfo: true });
|
||||
logger.info(`Log level is set to ${Config.LOGGER_CONFIG.LOG_LEVEL}.`, { bootInfo: true });
|
||||
|
||||
if (Config.ALLNET_CONFIG.ENABLED) {
|
||||
allnetServer.listen(Config.ALLNET_CONFIG.PORT, Config.LISTEN_ADDRESS);
|
||||
}
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
if (Config.AIMEDB_CONFIG.ENABLED) {
|
||||
const server = net.createServer(aimeDbServerFactory());
|
||||
logger.info("Initializing database connection...");
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -5,10 +5,8 @@ import {
|
|||
AimeAccountExtendedQueryStruct,
|
||||
} from "../types/aime-account";
|
||||
import { PacketHeaderStruct } from "../types/header";
|
||||
import { CommandId, PortalRegistration, ResultCodes } from "../utils/misc";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "external/db/db";
|
||||
import { card } from "external/db/schemas";
|
||||
import { CommandId, PortalRegistration, ResultCodes } from "../../../lib/constants/aimedb";
|
||||
import { AimeCard } from "external/db/entity/aimedb";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
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 cardRow = await db
|
||||
.select()
|
||||
.from(card)
|
||||
.where(eq(card.accessCode, accessCode))
|
||||
.then((r) => r[0]);
|
||||
const card = await AimeCard.findOne({
|
||||
where: { accessCode },
|
||||
});
|
||||
|
||||
if (!cardRow) {
|
||||
if (!card) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
if (cardRow.isBanned && cardRow.isLocked) {
|
||||
if (card.isBanned && card.isLocked) {
|
||||
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
|
||||
} else if (cardRow.isBanned) {
|
||||
} else if (card.isBanned) {
|
||||
header.result = ResultCodes.BAN_SYSTEM_LOCK;
|
||||
} else if (cardRow.isLocked) {
|
||||
} else if (card.isLocked) {
|
||||
header.result = ResultCodes.USER_LOCK;
|
||||
}
|
||||
|
||||
resp.accountId = cardRow.id;
|
||||
resp.accountId = card.id;
|
||||
return resp;
|
||||
};
|
||||
|
||||
|
@ -86,31 +82,29 @@ export const RegisterAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse">
|
|||
|
||||
const accessCode = Buffer.from(req.accessCode).toString("hex");
|
||||
|
||||
const cardRow = await db
|
||||
.select()
|
||||
.from(card)
|
||||
.where(eq(card.accessCode, accessCode))
|
||||
.then((r) => r[0]);
|
||||
const card = await AimeCard.findOne({
|
||||
where: {
|
||||
accessCode,
|
||||
},
|
||||
});
|
||||
|
||||
if (cardRow) {
|
||||
if (card) {
|
||||
header.result = ResultCodes.ID_ALREADY_REGISTERED;
|
||||
resp.accountId = cardRow.id;
|
||||
resp.accountId = card.id;
|
||||
return resp;
|
||||
}
|
||||
|
||||
const newCardRow = await db
|
||||
.insert(card)
|
||||
.values({ accessCode })
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
const newCard = AimeCard.construct({ accessCode });
|
||||
|
||||
if (!newCardRow) {
|
||||
logger.crit("Failed to insert new lookup entry into the database.", { accessCode });
|
||||
try {
|
||||
await newCard.save();
|
||||
} catch (err) {
|
||||
logger.crit("Failed to insert new lookup entry into the database.", { err });
|
||||
header.result = ResultCodes.UNKNOWN_ERROR;
|
||||
return resp;
|
||||
}
|
||||
|
||||
resp.accountId = newCardRow.id;
|
||||
resp.accountId = newCard.id;
|
||||
return resp;
|
||||
};
|
||||
|
||||
|
@ -126,7 +120,6 @@ export const GetAimeAccountExtendedHandler: AimeDBHandlerFn<"AimeAccountExtended
|
|||
const resp = new AimeAccountExtendedResponseStruct();
|
||||
|
||||
// TODO: Actually handle portal state when we get a webUI
|
||||
// TODO: What the fuck is an auth key
|
||||
resp.portalRegistered = PortalRegistration.UNREGISTERED;
|
||||
resp.accountId = -1;
|
||||
|
||||
|
@ -140,25 +133,23 @@ export const GetAimeAccountExtendedHandler: AimeDBHandlerFn<"AimeAccountExtended
|
|||
|
||||
const accessCode = Buffer.from(req.accessCode).toString("hex");
|
||||
|
||||
const cardRow = await db
|
||||
.select()
|
||||
.from(card)
|
||||
.where(eq(card.accessCode, accessCode))
|
||||
.then((r) => r[0]);
|
||||
const card = await AimeCard.findOne({
|
||||
where: { accessCode },
|
||||
});
|
||||
|
||||
if (!cardRow) {
|
||||
if (!card) {
|
||||
resp.accountId = -1;
|
||||
return resp;
|
||||
}
|
||||
|
||||
if (cardRow.isBanned && cardRow.isLocked) {
|
||||
if (card.isBanned && card.isLocked) {
|
||||
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
|
||||
} else if (cardRow.isBanned) {
|
||||
} else if (card.isBanned) {
|
||||
header.result = ResultCodes.BAN_SYSTEM_LOCK;
|
||||
} else if (cardRow.isLocked) {
|
||||
} else if (card.isLocked) {
|
||||
header.result = ResultCodes.USER_LOCK;
|
||||
}
|
||||
|
||||
resp.accountId = cardRow.id;
|
||||
resp.accountId = card.id;
|
||||
return resp;
|
||||
};
|
||||
|
|
|
@ -5,9 +5,9 @@ import {
|
|||
StatusLogStruct,
|
||||
} from "../types/aime-log";
|
||||
import { PacketHeaderStruct } from "../types/header";
|
||||
import { CommandId, LogStatus, ResultCodes } from "../utils/misc";
|
||||
import { db } from "external/db/db";
|
||||
import { eventLog } from "external/db/schemas";
|
||||
import { CommandId, LogStatus, ResultCodes } from "../../../lib/constants/aimedb";
|
||||
import { AppDataSource } from "external/db/data-source";
|
||||
import { EventLog } from "external/db/entity/base";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import type { AimeDBHandlerFn } from "../types/handlers";
|
||||
|
||||
|
@ -27,13 +27,15 @@ export const StatusLogHandler: AimeDBHandlerFn = async (header, data) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
await db.insert(eventLog).values({
|
||||
const eventLog = EventLog.construct({
|
||||
system: "aimedb",
|
||||
type: `AIMEDB_LOG_${statusName}`,
|
||||
type: `LOG_${statusName}`,
|
||||
severity: "info",
|
||||
details: { aimeId: req.aimeId },
|
||||
});
|
||||
|
||||
await eventLog.save();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -51,9 +53,9 @@ export const AimeLogHandler: AimeDBHandlerFn = async (header, data) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
await db.insert(eventLog).values({
|
||||
const eventLog = EventLog.construct({
|
||||
system: "aimedb",
|
||||
type: `AIMEDB_LOG_${statusName}`,
|
||||
type: `LOG_${statusName}`,
|
||||
severity: "info",
|
||||
details: {
|
||||
aimeId: req.aimeId,
|
||||
|
@ -64,6 +66,8 @@ export const AimeLogHandler: AimeDBHandlerFn = async (header, data) => {
|
|||
},
|
||||
});
|
||||
|
||||
await eventLog.save();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -78,7 +82,7 @@ export const AimeExtendedLogHandler: AimeDBHandlerFn<"AimeLogExtendedResponse">
|
|||
const req = new ExtendedAimeLogStruct(data);
|
||||
const resp = new AimeLogExtendedResponseStruct();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await AppDataSource.transaction(async (em) => {
|
||||
const ops = [];
|
||||
|
||||
for (let i = 0; i < req.count; i++) {
|
||||
|
@ -100,21 +104,20 @@ export const AimeExtendedLogHandler: AimeDBHandlerFn<"AimeLogExtendedResponse">
|
|||
continue;
|
||||
}
|
||||
|
||||
ops.push(
|
||||
tx.insert(eventLog).values({
|
||||
system: "aimedb",
|
||||
type: `AIMEDB_LOG_${statusName}`,
|
||||
severity: "info",
|
||||
details: {
|
||||
aimeId: entry.aimeId,
|
||||
userId: entry.userId.toString(),
|
||||
creditCount: entry.creditCount,
|
||||
betCount: entry.betCount,
|
||||
wonCount: entry.wonCount,
|
||||
},
|
||||
})
|
||||
);
|
||||
resp.result[i] = 1;
|
||||
const eventLog = EventLog.construct({
|
||||
system: "aimedb",
|
||||
type: `LOG_${statusName}`,
|
||||
severity: "info",
|
||||
details: {
|
||||
aimeId: entry.aimeId,
|
||||
userId: entry.userId.toString(),
|
||||
creditCount: entry.creditCount,
|
||||
betCount: entry.betCount,
|
||||
wonCount: entry.wonCount,
|
||||
},
|
||||
});
|
||||
|
||||
ops.push(em.save(eventLog));
|
||||
}
|
||||
|
||||
await Promise.all(ops);
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
OldCampaignResponseStruct,
|
||||
} from "../types/campaign";
|
||||
import { PacketHeaderStruct } from "../types/header";
|
||||
import { CommandId, ResultCodes } from "../utils/misc";
|
||||
import { CommandId, ResultCodes } from "../../../lib/constants/aimedb";
|
||||
import type { AimeDBHandlerFn } from "../types/handlers";
|
||||
|
||||
export const GetCampaignInfoHandler: AimeDBHandlerFn<"CampaignResponse" | "OldCampaignResponse"> = (
|
||||
|
|
|
@ -17,29 +17,29 @@ import {
|
|||
import { PacketHeaderStruct } from "../types/header";
|
||||
import { CalculateAccessCode } from "../utils/access-code";
|
||||
import { IsSupportedFelicaMobile, IsSupportedFelica } from "../utils/felica";
|
||||
import { CommandId, CompanyCodes, PortalRegistration, ResultCodes } from "../utils/misc";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "external/db/db";
|
||||
import { card, felicaCardLookup, felicaMobileLookup } from "external/db/schemas/index";
|
||||
import { AimeCard, FelicaCardLookup, FelicaMobileLookup } from "external/db/entity/aimedb";
|
||||
import { CommandId, CompanyCodes, PortalRegistration, ResultCodes } from "lib/constants/aimedb";
|
||||
import { DATA_FORMAT_CODE_MAP } from "lib/constants/felica";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { Config } from "lib/setup/config";
|
||||
import type { AimeDBHandlerFn } from "../types/handlers";
|
||||
import type { ValidFeliCaIcTypes } from "../utils/felica";
|
||||
import type { FelicaBaseLookup } from "external/db/entity/aimedb";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
async function createAndInsertNewAccessCode(
|
||||
table: typeof felicaCardLookup | typeof felicaMobileLookup,
|
||||
async function generateAndInsertNewAccessCode<T extends typeof FelicaBaseLookup>(
|
||||
table: T,
|
||||
cardType: "FeliCa Card" | "FeliCa Mobile",
|
||||
idm: bigint,
|
||||
prefix: string,
|
||||
key: string
|
||||
) {
|
||||
const mostRecentRow = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.orderBy(desc(table.id))
|
||||
.limit(1)
|
||||
.then((r) => r[0]);
|
||||
): Promise<FelicaBaseLookup | null> {
|
||||
const mostRecentRow = await table.findOne({
|
||||
order: {
|
||||
id: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const nextId = mostRecentRow ? mostRecentRow.id + 1 : 1;
|
||||
|
||||
|
@ -50,21 +50,59 @@ async function createAndInsertNewAccessCode(
|
|||
accessCode,
|
||||
});
|
||||
|
||||
const value = { idm: idm.toString(16), accessCode };
|
||||
const card = table.construct({ idm: idm.toString(16), accessCode });
|
||||
|
||||
const result = await db
|
||||
.insert(felicaCardLookup)
|
||||
.values(value)
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
|
||||
if (!result) {
|
||||
logger.crit("Failed to insert new lookup entry into the database.", value);
|
||||
try {
|
||||
await card.save();
|
||||
} catch (err) {
|
||||
logger.error("Failed to insert new lookup entry into the database.", { err });
|
||||
|
||||
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
|
||||
|
@ -82,44 +120,22 @@ export const FelicaLookupHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = asyn
|
|||
const req = new FelicaLookupRequestStruct(data);
|
||||
const resp = new FelicaLookupResponseStruct();
|
||||
|
||||
if (!IsSupportedFelica(req.osVer)) {
|
||||
if (!IsSupportedFelica(req.icType)) {
|
||||
header.result = ResultCodes.INVALID_AIME_ID;
|
||||
resp.felicaIndex = -1;
|
||||
return resp;
|
||||
}
|
||||
|
||||
const table = IsSupportedFelicaMobile(req.osVer) ? felicaMobileLookup : felicaCardLookup;
|
||||
const table = IsSupportedFelicaMobile(req.icType) ? FelicaMobileLookup : FelicaCardLookup;
|
||||
|
||||
let result = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.idm, req.idm.toString(16)))
|
||||
.then((r) => r[0]);
|
||||
let result: FelicaBaseLookup | null = await table.findOne({
|
||||
where: {
|
||||
idm: req.idm.toString(16),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
// Exit early if card is FeliCa mobile, so the game can use the register endpoint
|
||||
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
|
||||
);
|
||||
const tmp = await generateNewFeliCa(req.idm, req.icType);
|
||||
|
||||
if (!tmp) {
|
||||
header.result = ResultCodes.UNKNOWN_ERROR;
|
||||
|
@ -160,94 +176,46 @@ export const FelicaExtendedLookupHandler: AimeDBHandlerFn<"FelicaExtendedLookupR
|
|||
return resp;
|
||||
}
|
||||
|
||||
if (!IsSupportedFelica(req.osVer)) {
|
||||
if (!IsSupportedFelica(req.icType)) {
|
||||
header.result = ResultCodes.INVALID_AIME_ID;
|
||||
return resp;
|
||||
}
|
||||
|
||||
let result;
|
||||
const table = IsSupportedFelicaMobile(req.icType) ? FelicaMobileLookup : FelicaCardLookup;
|
||||
|
||||
if (IsSupportedFelicaMobile(req.osVer)) {
|
||||
result = await db
|
||||
.select()
|
||||
.from(felicaMobileLookup)
|
||||
.where(eq(felicaMobileLookup.idm, req.idm.toString(16)))
|
||||
.leftJoin(card, eq(card.accessCode, felicaMobileLookup.accessCode))
|
||||
.then((r) => r[0]);
|
||||
} 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]);
|
||||
}
|
||||
const result: FelicaBaseLookup | null = await table.findOne({
|
||||
where: {
|
||||
idm: req.idm.toString(16),
|
||||
},
|
||||
});
|
||||
|
||||
let accessCode;
|
||||
|
||||
if (result) {
|
||||
const lookupResult =
|
||||
"aimedb_felica_mobile_lookup" in result
|
||||
? result.aimedb_felica_mobile_lookup
|
||||
: result.aimedb_felica_card_lookup;
|
||||
const cardResult = result.aimedb_card;
|
||||
accessCode = result.accessCode;
|
||||
} else {
|
||||
const row = await generateNewFeliCa(req.idm, req.icType);
|
||||
|
||||
// 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(lookupResult.accessCode, "hex"));
|
||||
|
||||
if (cardResult) {
|
||||
resp.accountId = cardResult.id;
|
||||
resp.portalRegistered = PortalRegistration.UNREGISTERED;
|
||||
if (!row) {
|
||||
header.result = ResultCodes.UNKNOWN_ERROR;
|
||||
return resp;
|
||||
}
|
||||
|
||||
return resp;
|
||||
accessCode = row.accessCode;
|
||||
}
|
||||
|
||||
// Assuming that FeliCa Mobile is handled by their own registration endpoint...
|
||||
if (IsSupportedFelicaMobile(req.osVer)) {
|
||||
return resp;
|
||||
}
|
||||
// HACK: What does official AimeDB do if the DFC is not valid?
|
||||
resp.companyCode = DATA_FORMAT_CODE_MAP[req.dataFormatCode] ?? CompanyCodes.SEGA;
|
||||
resp.accessCode.set(Buffer.from(accessCode, "hex"));
|
||||
|
||||
// Card is not in the lookup tables, register a new card...
|
||||
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 }
|
||||
);
|
||||
const maybeCard = await AimeCard.findOne({
|
||||
where: { accessCode },
|
||||
});
|
||||
|
||||
header.result = ResultCodes.INVALID_AIME_ID;
|
||||
return resp;
|
||||
}
|
||||
|
||||
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;
|
||||
if (maybeCard) {
|
||||
// TODO: Actually handle portal state when we get a webUI
|
||||
resp.portalRegistered = PortalRegistration.UNREGISTERED;
|
||||
resp.accountId = maybeCard.id;
|
||||
}
|
||||
|
||||
return resp;
|
||||
|
@ -263,11 +231,11 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
|
|||
const req = new FelicaLookupRequestStruct(data);
|
||||
const resp = new FelicaLookupResponseStruct();
|
||||
|
||||
if (!IsSupportedFelicaMobile(req.osVer)) {
|
||||
if (!IsSupportedFelicaMobile(req.icType)) {
|
||||
logger.error("Rejecting card because it is not FeliCa Mobile.", {
|
||||
idm: req.idm,
|
||||
chipCode: req.chipCode,
|
||||
osVer: req.osVer,
|
||||
romType: req.romType,
|
||||
icType: req.icType,
|
||||
timing: req.timing,
|
||||
});
|
||||
|
||||
|
@ -276,12 +244,11 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
|
|||
return resp;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(felicaMobileLookup)
|
||||
.where(eq(felicaMobileLookup.idm, req.idm.toString(16)))
|
||||
.limit(1)
|
||||
.then((r) => r[0]);
|
||||
const result = await FelicaMobileLookup.findOne({
|
||||
where: {
|
||||
idm: req.idm.toString(16),
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
header.result = ResultCodes.ID_ALREADY_REGISTERED;
|
||||
|
@ -297,13 +264,13 @@ export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = as
|
|||
{ req }
|
||||
);
|
||||
|
||||
header.result = ResultCodes.INVALID_AIME_ID;
|
||||
header.result = ResultCodes.UNKNOWN_ERROR;
|
||||
resp.felicaIndex = -1;
|
||||
return resp;
|
||||
}
|
||||
|
||||
const row = await createAndInsertNewAccessCode(
|
||||
felicaMobileLookup,
|
||||
const row = await generateAndInsertNewAccessCode(
|
||||
FelicaMobileLookup,
|
||||
"FeliCa Mobile",
|
||||
req.idm,
|
||||
"01035",
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
FelicaRegisterHandler,
|
||||
} from "./felica-conversion";
|
||||
import { ServiceHealthCheckHandler } from "./status-check";
|
||||
import { CommandId } from "../utils/misc";
|
||||
import { CommandId } from "../../../lib/constants/aimedb";
|
||||
import type { AimeDBHandlerFn, AimeDBReturnTypes } from "../types/handlers";
|
||||
|
||||
type CommandIdRequest = {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { PacketHeaderStruct } from "../types/header";
|
||||
import { CommandId, ResultCodes } from "../utils/misc";
|
||||
import { CommandId, ResultCodes } from "../../../lib/constants/aimedb";
|
||||
import type { AimeDBHandlerFn } from "../types/handlers";
|
||||
|
||||
export const ServiceHealthCheckHandler: AimeDBHandlerFn = (header, _) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { AIMEDB_HANDLERS, EXPECTED_PACKET_LENGTH } from "./handlers";
|
||||
import { PacketHeaderStruct } from "./types/header";
|
||||
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 { Config } from "lib/setup/config";
|
||||
import { createHash } from "crypto";
|
||||
|
@ -124,7 +124,7 @@ const AimeDBServerFactory = () => {
|
|||
}
|
||||
|
||||
if (header.keychipId === "ABCD1234567" || header.storeId === 0xfff0) {
|
||||
logger.warning("Received request from uninitialized AMLib.", {
|
||||
logger.warn("Received request from uninitialized AMLib.", {
|
||||
ip: socket.remoteAddress,
|
||||
header,
|
||||
body: packet.slice(0, 32).toString("hex"),
|
||||
|
|
|
@ -24,7 +24,7 @@ export const AimeAccountExtendedResponseStruct = new Struct("AimeAccountExtended
|
|||
.UInt32LE("accountId")
|
||||
.UInt8("portalRegistered")
|
||||
.UInt8Array("padding", 3)
|
||||
.UInt8Array("authKey", 256)
|
||||
.UInt8Array("segaIdAuthKey", 256)
|
||||
.UInt32LE("relationId1")
|
||||
.UInt32LE("relationId2")
|
||||
.compile();
|
||||
|
|
|
@ -2,8 +2,8 @@ import Struct from "typed-struct";
|
|||
|
||||
export const FelicaLookupRequestStruct = new Struct("FelicaLookupRequest")
|
||||
.BigUInt64LE("idm")
|
||||
.UInt8("chipCode")
|
||||
.UInt8("osVer")
|
||||
.UInt8("romType")
|
||||
.UInt8("icType")
|
||||
.UInt8Array("timing", 6)
|
||||
.compile();
|
||||
|
||||
|
@ -16,15 +16,15 @@ export const FelicaLookupResponseStruct = new Struct("FelicaLookupResponse")
|
|||
export const FelicaExtendedLookupRequestStruct = new Struct("FelicaExtendedLookupRequest")
|
||||
.UInt8Array("randomChallenge", 16)
|
||||
.BigUInt64LE("idm")
|
||||
.UInt8("chipCode")
|
||||
.UInt8("osVer")
|
||||
.UInt8("romType")
|
||||
.UInt8("icType")
|
||||
.UInt8Array("timing", 6)
|
||||
.UInt8Array("cardKeyVersion", 16)
|
||||
.UInt8Array("writeCount", 16)
|
||||
.BigUInt64LE("maca")
|
||||
.UInt8("companyCode")
|
||||
.UInt8("readerFirmwareVersion")
|
||||
.UInt16LE("DFC")
|
||||
.UInt16LE("dataFormatCode")
|
||||
.UInt8Array("padding", 4)
|
||||
.compile();
|
||||
|
||||
|
@ -36,5 +36,5 @@ export const FelicaExtendedLookupResponseStruct = new Struct("FelicaExtendedLook
|
|||
.UInt8("portalRegistered")
|
||||
.UInt8("companyCode")
|
||||
.UInt8Array("padding", 8)
|
||||
.UInt8Array("authKey", 256)
|
||||
.UInt8Array("segaIdAuthKey", 256)
|
||||
.compile();
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -2,19 +2,29 @@ import { Solitaire } from "./crypto";
|
|||
import { createHash } from "crypto";
|
||||
import type { integer } from "types/misc";
|
||||
|
||||
function reverseString(data: string) {
|
||||
return Array.from(data).reverse().join("");
|
||||
}
|
||||
|
||||
function CalculateCardKey(serial: integer, key: string) {
|
||||
/* eslint-disable no-bitwise */
|
||||
export function CalculateCardKey(serial: integer, key: string) {
|
||||
const keyBuffer = Buffer.from(key, "hex");
|
||||
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++) {
|
||||
const idx = Number(`0x${key[i]}`);
|
||||
const nib = realDigest[idx];
|
||||
// Extract the i-th hexadecimal digit of the key...
|
||||
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) {
|
||||
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
|
||||
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");
|
||||
return hash.toString().padStart(7, "0");
|
||||
}
|
||||
/* eslint-enable no-bitwise */
|
||||
|
||||
export function CalculateAccessCode(serial: integer, prefix: string, key: string): string {
|
||||
const digest = CalculateCardKey(serial, key);
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
const FELICA_MOBILE_OS_VERSIONS = [0x06, 0x07, 0x10, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18] as const;
|
||||
const FELICA_CARD_OS_VERSIONS = [0x20, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7] as const;
|
||||
import { FELICA_CARD_IC_TYPES, FELICA_MOBILE_IC_TYPES } from "lib/constants/felica";
|
||||
|
||||
export type ValidFeliCaIcTypes =
|
||||
| typeof FELICA_CARD_IC_TYPES[number]
|
||||
| typeof FELICA_MOBILE_IC_TYPES[number];
|
||||
|
||||
export function IsSupportedFelicaMobile(
|
||||
osVer: number
|
||||
): osVer is typeof FELICA_MOBILE_OS_VERSIONS[number] {
|
||||
return (FELICA_MOBILE_OS_VERSIONS as ReadonlyArray<number>).includes(osVer);
|
||||
icType: number
|
||||
): icType is typeof FELICA_MOBILE_IC_TYPES[number] {
|
||||
return (FELICA_MOBILE_IC_TYPES as ReadonlyArray<number>).includes(icType);
|
||||
}
|
||||
|
||||
export function IsSupportedFelicaCard(
|
||||
osVer: number
|
||||
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] {
|
||||
return (FELICA_CARD_OS_VERSIONS as ReadonlyArray<number>).includes(osVer);
|
||||
icType: number
|
||||
): icType is typeof FELICA_CARD_IC_TYPES[number] {
|
||||
return (FELICA_CARD_IC_TYPES as ReadonlyArray<number>).includes(icType);
|
||||
}
|
||||
|
||||
export function IsSupportedFelica(
|
||||
osVer: number
|
||||
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] | typeof FELICA_MOBILE_OS_VERSIONS[number] {
|
||||
return IsSupportedFelicaMobile(osVer) || IsSupportedFelicaCard(osVer);
|
||||
export function IsSupportedFelica(icType: number): icType is ValidFeliCaIcTypes {
|
||||
return IsSupportedFelicaMobile(icType) || IsSupportedFelicaCard(icType);
|
||||
}
|
||||
|
|
|
@ -4,12 +4,11 @@
|
|||
import express from "express";
|
||||
import "express-async-errors";
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import mainRouter from "./router/router";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { DFIRequestResponse } from "./middleware/dfi";
|
||||
import type { Express } from "express";
|
||||
import { RequestLoggerMiddleware } from "./middleware/request-logger";
|
||||
import { RequestLoggerMiddleware } from "../../lib/middleware/request-logger";
|
||||
import { IsRecord } from "utils/misc";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
@ -30,10 +29,16 @@ process.on("unhandledRejection", (reason, promise) => {
|
|||
logger.error(reason, { promise });
|
||||
});
|
||||
|
||||
// ALL.Net sends a form body that is sometimes deflated and base64-encoded.
|
||||
// This also does some special handling to prevent keys/values from being
|
||||
// URL-escaped, which ALL.Net doesn't do. Pray they don't need an & or a =.
|
||||
app.use(DFIRequestResponse);
|
||||
// We do the body handling here so logging is properly handled.
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === "/sys/servlet/PowerOn") {
|
||||
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) => {
|
||||
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
|
||||
|
@ -50,6 +55,12 @@ app.use(RequestLoggerMiddleware);
|
|||
app.use("/", mainRouter);
|
||||
|
||||
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 });
|
||||
|
||||
const unknownErr = err as unknown;
|
||||
|
|
|
@ -6,59 +6,61 @@ import type { RequestHandler } from "express";
|
|||
|
||||
const inflateAsync = promisify(inflate);
|
||||
|
||||
export const DFIRequestResponse: RequestHandler = async (req, res, next) => {
|
||||
if (Number(req.headers["content-length"] ?? 0) === 0) {
|
||||
export const DFIRequestResponse: (mustUseDfi: boolean) => RequestHandler = (mustUseDfi) => {
|
||||
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();
|
||||
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();
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,3 +1,4 @@
|
|||
import reportRouter from "./report-api/router";
|
||||
import sysServletRouter from "./sys/servlet/router";
|
||||
import { Router } from "express";
|
||||
|
||||
|
@ -8,5 +9,6 @@ router.all("/naomitest.html", (_, res) => {
|
|||
});
|
||||
|
||||
router.use("/sys/servlet", sysServletRouter);
|
||||
router.use("/report-api", reportRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -1,19 +1,44 @@
|
|||
import { Router } from "express";
|
||||
import { Machine } from "external/db/entity/allnet";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { Config } from "lib/setup/config";
|
||||
import {
|
||||
DownloadOrderRequestSchema,
|
||||
DownloadOrderStatus,
|
||||
} from "servers/allnet/types/download-order";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { DownloadOrderResponse } from "servers/allnet/types/download-order";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
router.post("/", async (req, res) => {
|
||||
const parseResult = DownloadOrderRequestSchema.safeParse(req.safeBody);
|
||||
|
||||
if (!parseResult.success) {
|
||||
logger.error("Received invalid download order request.", {
|
||||
error: fromZodError(parseResult.error).message,
|
||||
body: req.safeBody,
|
||||
});
|
||||
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.
|
||||
const response = {
|
||||
stat: DownloadOrderStatus.FAILURE,
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { db } from "external/db/db";
|
||||
import { arcade, machine } from "external/db/schemas";
|
||||
import { Machine } from "external/db/entity/allnet";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { Config } from "lib/setup/config";
|
||||
import { DateTime } from "luxon";
|
||||
|
@ -33,11 +31,10 @@ router.post("/", async (req, res) => {
|
|||
return res.status(500).send();
|
||||
}
|
||||
|
||||
const formatVer = req.safeBody.format_ver ?? "1";
|
||||
const formatVer = (req.safeBody.format_ver as string | undefined) ?? "1";
|
||||
|
||||
let parseResult;
|
||||
|
||||
// TODO: ALL.Net China
|
||||
if (formatVer === "1") {
|
||||
parseResult = PowerOnRequestV1Schema.safeParse(req.safeBody);
|
||||
} else if (formatVer === "2") {
|
||||
|
@ -57,8 +54,10 @@ router.post("/", async (req, res) => {
|
|||
}
|
||||
|
||||
if (!parseResult.success) {
|
||||
logger.error(`Received invalid PowerOn request: ${fromZodError(parseResult.error)}`, {
|
||||
logger.error(`Received invalid PowerOn request.`, {
|
||||
err: fromZodError(parseResult.error).message,
|
||||
formatVer,
|
||||
body: req.safeBody,
|
||||
});
|
||||
return res.status(400).send("");
|
||||
}
|
||||
|
@ -96,14 +95,13 @@ router.post("/", async (req, res) => {
|
|||
// TODO: Implement store authentication + fetch arcade information
|
||||
// Reference implementation: https://sega.bsnk.me/allnet/auth/power-on
|
||||
// For now, we just check if there's a cab registered in the database.
|
||||
const cabAndStore = await db
|
||||
.select()
|
||||
.from(machine)
|
||||
.leftJoin(arcade, eq(arcade.id, machine.arcade_id))
|
||||
.where(eq(machine.serial, serial))
|
||||
.then((r) => r[0]);
|
||||
const result = await Machine.findOne({
|
||||
where: {
|
||||
serial,
|
||||
},
|
||||
});
|
||||
|
||||
if (!cabAndStore && !Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
|
||||
if (!result && !Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
|
||||
logger.error("Unregistered serial attempted ALL.Net authentication.", {
|
||||
gameId,
|
||||
serial,
|
||||
|
@ -115,21 +113,19 @@ router.post("/", async (req, res) => {
|
|||
// TODO: Verify that title exists and is enabled.
|
||||
const serverTime = DateTime.now().setZone("Asia/Tokyo");
|
||||
const baseResponse = {
|
||||
// Same thing
|
||||
stat: 1,
|
||||
|
||||
place_id: (0x123).toString(16),
|
||||
place_id: (result?.arcade.id ?? 0x123).toString(16).toUpperCase(),
|
||||
// uri: `http://localhost:8080/${gameId}/${titleVer.replace(/\./u, "")}/`,
|
||||
uri: `http://localhost:8080/${titleVer.replace(/\./u, "")}/`,
|
||||
host: "localhost:8080",
|
||||
name: cabAndStore?.allnet_arcade?.name ?? Config.NAME,
|
||||
nickname: cabAndStore?.allnet_arcade?.nickname ?? "kozukata-toa",
|
||||
name: result?.arcade.name ?? Config.NAME,
|
||||
nickname: result?.arcade.nickname ?? "kozukatatoa",
|
||||
setting: 1,
|
||||
region0: cabAndStore?.allnet_arcade?.regionId ?? 1,
|
||||
region_name0: cabAndStore?.allnet_arcade?.regionName0 ?? "W",
|
||||
region_name1: cabAndStore?.allnet_arcade?.regionName1 ?? "",
|
||||
region_name2: cabAndStore?.allnet_arcade?.regionName2 ?? "",
|
||||
region_name3: cabAndStore?.allnet_arcade?.regionName3 ?? "",
|
||||
region0: result?.arcade.regionId ?? 1,
|
||||
region_name0: result?.arcade.regionName0 ?? "W",
|
||||
region_name1: result?.arcade.regionName1 ?? "",
|
||||
region_name2: result?.arcade.regionName2 ?? "",
|
||||
region_name3: result?.arcade.regionName3 ?? "",
|
||||
};
|
||||
|
||||
let response;
|
||||
|
@ -164,7 +160,7 @@ router.post("/", async (req, res) => {
|
|||
allnet_id: 456,
|
||||
utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
country: "JPN",
|
||||
client_timezone: "+0900",
|
||||
client_timezone: " 0900",
|
||||
token: request.token ?? "null",
|
||||
} satisfies PowerOnResponseV3;
|
||||
} else if (request.format_ver === "3" && "game_ver" in request) {
|
||||
|
|
|
@ -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"
|
||||
);
|
|
@ -1,16 +1,18 @@
|
|||
import { z } from "zod";
|
||||
import type { integer } from "types/misc";
|
||||
|
||||
const zodCoerceOptionalFiveDigitInteger = z.coerce.number().int().gte(-99999).lte(99999).optional();
|
||||
|
||||
export const PowerOnRequestV1Schema = z.object({
|
||||
game_id: z.string().max(5),
|
||||
ver: z.string().max(5),
|
||||
serial: z.string().max(11),
|
||||
ip: z.string().ip(),
|
||||
firm_ver: z.coerce.number().optional(),
|
||||
boot_ver: z.coerce.number().optional(),
|
||||
firm_ver: zodCoerceOptionalFiveDigitInteger,
|
||||
boot_ver: zodCoerceOptionalFiveDigitInteger,
|
||||
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
|
||||
format_ver: z.literal("1"),
|
||||
hops: z.coerce.number().default(-1),
|
||||
format_ver: z.literal("1").optional(),
|
||||
hops: z.coerce.number().int().gte(-99999).lte(99999).default(-1),
|
||||
});
|
||||
|
||||
export type PowerOnRequestV1 = z.infer<typeof PowerOnRequestV1Schema>;
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export { default as allnetServer } from "./allnet";
|
||||
export { default as aimeDbServerFactory } from "./aimedb";
|
||||
export { default as titleServer } from "./titles";
|
||||
|
|
|
@ -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;
|
|
@ -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" });
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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: [],
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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: [],
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -15,8 +15,8 @@
|
|||
"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. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
"experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
"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'. */
|
||||
// "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*'. */
|
||||
|
@ -85,7 +85,7 @@
|
|||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "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. */
|
||||
// "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. */
|
||||
|
|
Loading…
Reference in New Issue