push: rolling in hacked up title server

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

View File

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

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,34 +24,89 @@ const config: unknown = JSON5.parse(configFile);
const zod16bitNumber = z.number().gt(0).lte(65535);
const 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",
};

View File

@ -1,19 +1,41 @@
import { aimeDbServerFactory, allnetServer } from "./servers/index";
import { aimeDbServerFactory, allnetServer, titleServer } from "./servers/index";
import { AppDataSource } from "external/db/data-source";
import CreateLogCtx from "lib/logger/logger";
import { 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);
}
});

View File

@ -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;
};

View File

@ -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);

View File

@ -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"> = (

View File

@ -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",

View File

@ -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 = {

View File

@ -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, _) => {

View File

@ -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"),

View File

@ -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();

View File

@ -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();

View File

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

View File

@ -2,19 +2,29 @@ import { Solitaire } from "./crypto";
import { createHash } from "crypto";
import 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);

View File

@ -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);
}

View File

@ -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;

View File

@ -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();
};

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import reportRouter from "./report-api/router";
import sysServletRouter from "./sys/servlet/router";
import { 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;

View File

@ -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,

View File

@ -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) {

View File

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

View File

@ -1,16 +1,18 @@
import { z } from "zod";
import 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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,8 +15,8 @@
"target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"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. */