Initial commit

This commit is contained in:
beerpiss 2023-11-21 21:22:03 +07:00
commit ad0e31bf55
53 changed files with 8041 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = tab
trim_trailing_whitespace = true

4
.eslintignore Normal file
View File

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

48
.eslintrc Normal file
View File

@ -0,0 +1,48 @@
{
"plugins": [
"cadence"
],
"extends": [
"plugin:cadence/recommended"
],
"root": true,
"rules": {},
"overrides": [
{
"files": [
"**/*.ts"
],
"rules": {
// broken
"lines-around-comment": "off",
// TENPORARILY OFF AS THEY'RE BROKEN.
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-property-computation": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/strict-boolean-expressions": "off"
}
},
{
"files": [
"**/*.test.ts"
],
"rules": {
// Our test files break these rules *all* the time, and there's
// no point refactoring. Our tests deliberately play with the
// dynamic nature of TS to more accurately test arbitrary input.
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
// we abuse this one for faking data, but we really shouldn't.
"@typescript-eslint/consistent-type-assertions": "warn"
}
}
]
}

153
.gitignore vendored Normal file
View File

@ -0,0 +1,153 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
config.json5
js
.tsbuildinfo

14
@types/express/index.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare global {
namespace Express {
interface Request {
/**
* This is a type-safe variant of "req.safeBody".
* "req.safeBody" is 'any' by default, which makes it exceptionally difficult
* to use in our codebase (due to the strict cadence rules.)
*/
safeBody: Record<string, unknown>;
}
}
}
export {};

6
drizzle.config.ts Normal file
View File

@ -0,0 +1,6 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/external/db/schemas/",
out: "./src/external/db/drizzle",
} satisfies Config;

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "kozukata-toa",
"version": "0.1.0",
"description": "An ALL.Net network service emulator.",
"main": "./js/main.js",
"files": [
"/js"
],
"private": true,
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --project tsconfig.build.json --noEmit",
"start": "pnpm build && pnpm start-no-build",
"start-no-build": "node js/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "beerpsi",
"license": "0BSD",
"engines": {
"node": "16",
"pnpm": "7"
},
"dependencies": {
"better-sqlite3": "^9.1.1",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.29.0",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"fletcher": "^0.0.3",
"iconv-lite": "^0.6.3",
"json5": "^2.2.3",
"luxon": "^3.4.4",
"micro-packed": "^0.3.2",
"raw-body": "^2.5.2",
"safe-json-stringify": "^1.2.0",
"tsconfig-paths": "^4.2.0",
"typed-struct": "^2.3.0",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"zod": "^3.22.4",
"zod-validation-error": "^2.1.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.7",
"@types/express": "^4.17.21",
"@types/iconv": "^3.0.4",
"@types/luxon": "^3.3.4",
"@types/node": "16",
"@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",
"typescript": "4.9.4"
}
}

4071
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,10 @@
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

@ -0,0 +1,48 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,317 @@
{
"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

@ -0,0 +1,494 @@
{
"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

@ -0,0 +1,20 @@
{
"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
}
]
}

67
src/external/db/schemas/aimedb.ts vendored Normal file
View File

@ -0,0 +1,67 @@
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),
})
);

56
src/external/db/schemas/allnet.ts vendored Normal file
View File

@ -0,0 +1,56 @@
/**
* 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;

16
src/external/db/schemas/base.ts vendored Normal file
View File

@ -0,0 +1,16 @@
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;

3
src/external/db/schemas/index.ts vendored Normal file
View File

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

195
src/lib/logger/logger.ts Normal file
View File

@ -0,0 +1,195 @@
import { Config } from "lib/setup/config";
import SafeJSONStringify from "safe-json-stringify";
import { EscapeStringRegexp } from "utils/misc";
import winston, { format, transports } from "winston";
import "winston-daily-rotate-file";
const level = process.env.LOG_LEVEL ?? Config.LOGGER_CONFIG.LOG_LEVEL;
const formatExcessProperties = (meta: Record<string, unknown>, limit = false) => {
let i = 0;
for (const [key, val] of Object.entries(meta)) {
// this is probably fine
// eslint-disable-next-line cadence/no-instanceof
if (val instanceof Error) {
meta[key] = { message: val.message, stack: val.stack };
}
i++;
}
if (!i) {
return "";
}
const content = SafeJSONStringify(meta);
return ` ${limit ? StrCap(content) : content}`;
};
const formatExcessPropertiesNoStack = (
meta: Record<string, unknown>,
omitKeys: Array<string> = [],
limit = false
) => {
const realMeta: Record<string, unknown> = {};
for (const [key, val] of Object.entries(meta)) {
if (omitKeys.includes(key)) {
continue;
}
// this is probably fine
// eslint-disable-next-line cadence/no-instanceof
if (val instanceof Error) {
realMeta[key] = { message: val.message };
} else if (!key.startsWith("__") && !key.startsWith("!")) {
realMeta[key] = val;
}
}
if (Object.keys(realMeta).length === 0) {
return "";
}
const content = SafeJSONStringify(realMeta);
return ` ${limit ? StrCap(content) : content}`;
};
function StrCap(string: string) {
if (string.length > 300) {
return `${string.slice(0, 297)}...`;
}
return string;
}
const printf = format.printf(
({ level, message, context = "toa-root", timestamp, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessProperties(meta)}`
);
const consolePrintf = format.printf(
({ level, message, context = "toa-root", timestamp, hideFromConsole, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessPropertiesNoStack(
meta,
hideFromConsole as Array<string>,
true
)}`
);
winston.addColors({
crit: ["bgRed", "black"],
error: ["red"],
warn: ["yellow"],
info: ["blue"],
verbose: ["cyan"],
debug: ["white"],
});
const baseFormatter = format.combine(
format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
})
);
const defaultFormatter = format.combine(baseFormatter, format.errors({ stack: false }), printf);
const consoleFormatter = format.combine(
baseFormatter,
format.errors({ stack: false }),
consolePrintf,
format.colorize({
all: true,
})
);
const tports: Array<winston.transport> = [];
if (Config.LOGGER_CONFIG.CONSOLE || process.env.FORCE_CONSOLE_LOG) {
tports.push(
new transports.Console({
format: consoleFormatter,
})
);
}
if (Config.LOGGER_CONFIG.FOLDER) {
tports.push(
new transports.DailyRotateFile({
filename: `${Config.LOGGER_CONFIG.FOLDER}/toa-%DATE%.log`,
datePattern: "YYYY-MM-DD-HH",
zippedArchive: true,
maxSize: "20m",
maxFiles: "14d",
createSymlink: true,
symlinkName: "toa.log",
format: defaultFormatter,
})
);
}
export const rootLogger = winston.createLogger({
level,
format: defaultFormatter,
transports: tports,
defaultMeta: {
__ServerName: Config.NAME,
},
});
if (tports.length === 0) {
// eslint-disable-next-line no-console
console.warn(
"You have no transports set. Absolutely no logs will be saved. This is a terrible idea!"
);
}
export default function CreateLogCtx(filename: string, lg = rootLogger): winston.Logger {
const replacedFilename = filename.replace(
new RegExp(`^${EscapeStringRegexp(process.cwd())}[\\\\/]((js|src)[\\\\/])?`, "u"),
""
);
const logger = lg.child({
context: [replacedFilename],
});
// @hack, defaultMeta isn't reactive -- won't be updated unless we do this.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
logger.defaultMeta = { ...(logger.defaultMeta ?? {}), context: [replacedFilename] };
return logger;
}
export function AppendLogCtx(context: string, lg: winston.Logger): winston.Logger {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const newContext = [...lg.defaultMeta.context, context];
return lg.child({ context: newContext });
}
export function ChangeRootLogLevel(
level: "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn"
) {
rootLogger.info(`Changing log level to ${level}.`);
for (const tp of tports) {
tp.level = level;
}
}
export function GetLogLevel() {
return (
tports.map((e) => e.level).find((e) => typeof e === "string") ??
Config.LOGGER_CONFIG.LOG_LEVEL
);
}
export const Transports = tports;

66
src/lib/setup/config.ts Normal file
View File

@ -0,0 +1,66 @@
import dotenv from "dotenv";
import JSON5 from "json5";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import fs from "fs";
dotenv.config();
const logger = console;
const confLocation = process.env.TOA_CONFIG ?? "./config.json5";
let configFile;
try {
configFile = fs.readFileSync(confLocation, "utf-8");
} catch (err) {
logger.error("Error while trying to open conf.json5. Is one present?", { err });
process.exit(1);
}
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 configSchema = z.object({
NAME: z.string(),
DATABASE_PATH: z.string(),
LISTEN_ADDRESS: z.string().ip(),
LOGGER_CONFIG: z.object({
LOG_LEVEL: zodLogLevel,
CONSOLE: z.boolean(),
FOLDER: z.string().optional(),
}),
ALLNET_CONFIG: z.object({
ENABLED: z.boolean(),
PORT: zod16bitNumber,
ALLOW_UNREGISTERED_SERIALS: z.boolean(),
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,
}),
});
const parseResult = configSchema.safeParse(config);
if (!parseResult.success) {
const humanFriendlyMessage = fromZodError(parseResult.error);
throw new Error(`Invalid config.json5 file: ${humanFriendlyMessage}`);
}
export const Config = parseResult.data;

19
src/main.ts Normal file
View File

@ -0,0 +1,19 @@
import { aimeDbServerFactory, allnetServer } from "./servers/index";
import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import net from "net";
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);
}
if (Config.AIMEDB_CONFIG.ENABLED) {
const server = net.createServer(aimeDbServerFactory());
server.listen(Config.AIMEDB_CONFIG.PORT, Config.LISTEN_ADDRESS);
}

View File

@ -0,0 +1,164 @@
import {
AimeAccountQueryStruct,
AimeAccountResponseStruct,
AimeAccountExtendedResponseStruct,
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 CreateLogCtx from "lib/logger/logger";
import type { AimeDBHandlerFn } from "../types/handlers";
const logger = CreateLogCtx(__filename);
export const GetAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse"> = async (
header,
data
) => {
header.length = PacketHeaderStruct.baseSize + AimeAccountResponseStruct.baseSize;
header.commandId = CommandId.AIME_ACCOUNT_RESPONSE;
header.result = ResultCodes.SUCCESS;
const req = new AimeAccountQueryStruct(data);
const resp = new AimeAccountResponseStruct();
// TODO: Actually handle portal state when we get a webUI
resp.portalRegistered = PortalRegistration.UNREGISTERED;
resp.accountId = -1;
if (req.companyCode < 0 || req.companyCode > 4) {
logger.error("Received unknown company code. Expected a value between 0 and 4 inclusive.", {
req,
});
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db
.select()
.from(card)
.where(eq(card.accessCode, accessCode))
.then((r) => r[0]);
if (!cardRow) {
return resp;
}
if (cardRow.isBanned && cardRow.isLocked) {
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
} else if (cardRow.isBanned) {
header.result = ResultCodes.BAN_SYSTEM_LOCK;
} else if (cardRow.isLocked) {
header.result = ResultCodes.USER_LOCK;
}
resp.accountId = cardRow.id;
return resp;
};
export const RegisterAimeAccountHandler: AimeDBHandlerFn<"AimeAccountResponse"> = async (
header,
data
) => {
header.length = PacketHeaderStruct.baseSize + AimeAccountResponseStruct.baseSize;
header.commandId = CommandId.AIME_ACCOUNT_RESPONSE;
header.result = ResultCodes.SUCCESS;
const req = new AimeAccountQueryStruct(data);
const resp = new AimeAccountResponseStruct();
// TODO: Actually handle portal state when we get a webUI
resp.portalRegistered = PortalRegistration.UNREGISTERED;
resp.accountId = -1;
if (req.companyCode < 0 || req.companyCode > 4) {
logger.error("Received unknown company code. Expected a value between 0 and 4 inclusive.", {
req,
});
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db
.select()
.from(card)
.where(eq(card.accessCode, accessCode))
.then((r) => r[0]);
if (cardRow) {
header.result = ResultCodes.ID_ALREADY_REGISTERED;
resp.accountId = cardRow.id;
return resp;
}
const newCardRow = await db
.insert(card)
.values({ accessCode })
.returning()
.then((r) => r[0]);
if (!newCardRow) {
logger.crit("Failed to insert new lookup entry into the database.", { accessCode });
header.result = ResultCodes.UNKNOWN_ERROR;
return resp;
}
resp.accountId = newCardRow.id;
return resp;
};
export const GetAimeAccountExtendedHandler: AimeDBHandlerFn<"AimeAccountExtendedResponse"> = async (
header,
data
) => {
header.length = PacketHeaderStruct.baseSize + AimeAccountExtendedResponseStruct.baseSize;
header.commandId = CommandId.EXTENDED_ACCOUNT_RESPONSE;
header.result = ResultCodes.SUCCESS;
const req = new AimeAccountExtendedQueryStruct(data);
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;
if (req.companyCode < 0 || req.companyCode > 4) {
logger.error("Received unknown company code. Expected a value between 0 and 4 inclusive.", {
req,
});
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
const accessCode = Buffer.from(req.accessCode).toString("hex");
const cardRow = await db
.select()
.from(card)
.where(eq(card.accessCode, accessCode))
.then((r) => r[0]);
if (!cardRow) {
resp.accountId = -1;
return resp;
}
if (cardRow.isBanned && cardRow.isLocked) {
header.result = ResultCodes.BAN_SYSTEM_AND_USER_LOCK;
} else if (cardRow.isBanned) {
header.result = ResultCodes.BAN_SYSTEM_LOCK;
} else if (cardRow.isLocked) {
header.result = ResultCodes.USER_LOCK;
}
resp.accountId = cardRow.id;
return resp;
};

View File

@ -0,0 +1,124 @@
import {
AimeLogStruct,
AimeLogExtendedResponseStruct,
ExtendedAimeLogStruct,
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 CreateLogCtx from "lib/logger/logger";
import type { AimeDBHandlerFn } from "../types/handlers";
const logger = CreateLogCtx(__filename);
export const StatusLogHandler: AimeDBHandlerFn = async (header, data) => {
header.commandId = CommandId.STATUS_LOG_RESPONSE;
header.result = ResultCodes.SUCCESS;
header.length = PacketHeaderStruct.baseSize;
const req = new StatusLogStruct(data);
const statusName = LogStatus[req.status];
if (!statusName) {
logger.error("Unknown status for logging. Expected a value between 0 and 4.", { req });
header.result = ResultCodes.UNKNOWN_ERROR;
return null;
}
await db.insert(eventLog).values({
system: "aimedb",
type: `AIMEDB_LOG_${statusName}`,
severity: "info",
details: { aimeId: req.aimeId },
});
return null;
};
export const AimeLogHandler: AimeDBHandlerFn = async (header, data) => {
header.commandId = CommandId.AIME_LOG_RESPONSE;
header.result = ResultCodes.SUCCESS;
header.length = PacketHeaderStruct.baseSize;
const req = new AimeLogStruct(data);
const statusName = LogStatus[req.status];
if (!statusName) {
logger.error("Unknown status for logging. Expected a value between 0 and 4.", { req });
header.result = ResultCodes.UNKNOWN_ERROR;
return null;
}
await db.insert(eventLog).values({
system: "aimedb",
type: `AIMEDB_LOG_${statusName}`,
severity: "info",
details: {
aimeId: req.aimeId,
userId: req.userId,
creditCount: req.creditCount,
betCount: req.betCount,
wonCount: req.wonCount,
},
});
return null;
};
export const AimeExtendedLogHandler: AimeDBHandlerFn<"AimeLogExtendedResponse"> = async (
header,
data
) => {
header.commandId = CommandId.EXTENDED_AIME_LOG_RESPONSE;
header.result = ResultCodes.SUCCESS;
header.length = PacketHeaderStruct.baseSize;
const req = new ExtendedAimeLogStruct(data);
const resp = new AimeLogExtendedResponseStruct();
await db.transaction(async (tx) => {
const ops = [];
for (let i = 0; i < req.count; i++) {
const entry = req.entries[i];
if (!entry) {
throw new Error(
"There was an undefined value in the log entries. This should not be possible!"
);
}
const statusName = LogStatus[entry.status];
if (!statusName) {
logger.error(
"Unknown status for logging. Expected a value between 0 and 4.",
entry
);
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;
}
await Promise.all(ops);
});
return resp;
};

View File

@ -0,0 +1,36 @@
// TODO: Actually support campaigns
import {
CampaignClearInfoResponseStruct,
CampaignResponseStruct,
OldCampaignResponseStruct,
} from "../types/campaign";
import { PacketHeaderStruct } from "../types/header";
import { CommandId, ResultCodes } from "../utils/misc";
import type { AimeDBHandlerFn } from "../types/handlers";
export const GetCampaignInfoHandler: AimeDBHandlerFn<"CampaignResponse" | "OldCampaignResponse"> = (
header,
_
) => {
header.commandId = CommandId.CAMPAIGN_INFO_RESPONSE;
header.result = ResultCodes.SUCCESS;
if (header.version < 0x3030) {
header.length = PacketHeaderStruct.baseSize + OldCampaignResponseStruct.baseSize;
return new OldCampaignResponseStruct();
}
header.length = PacketHeaderStruct.baseSize + CampaignResponseStruct.baseSize;
return new CampaignResponseStruct();
};
export const GetCampaignClearInfoHandler: AimeDBHandlerFn<"CampaignClearInfoResponse"> = (
header,
_
) => {
header.commandId = CommandId.CAMPAIGN_CLEAR_INFO_RESPONSE;
header.result = ResultCodes.SUCCESS;
header.length = PacketHeaderStruct.baseSize + CampaignClearInfoResponseStruct.baseSize;
return new CampaignClearInfoResponseStruct();
};

View File

@ -0,0 +1,271 @@
// On real ALL.Net, there's just a massive database of every supported FeliCa's
// IDm -> access code mappings. Since we don't have access to such information,
// every FeliCa card will be treated like FeliCa mobile: an access code is
// generated, and then stored with its IDm for future lookups.
//
// This also means you'll need to get some more keys, but if you're lazy
// you can probably just set
// - RESERVED_CARD_PREFIX to something not starting with 0 or 3
// - RESERVED_CARD_KEY to a random 16-digit hex string, where each digit displays
// exactly once.
import {
FelicaExtendedLookupRequestStruct,
FelicaExtendedLookupResponseStruct,
FelicaLookupRequestStruct,
FelicaLookupResponseStruct,
} from "../types/felica-conversion";
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 CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import type { AimeDBHandlerFn } from "../types/handlers";
const logger = CreateLogCtx(__filename);
export const FelicaLookupHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = async (
header,
data
) => {
header.commandId = CommandId.FELICA_LOOKUP_RESPONSE;
header.length = PacketHeaderStruct.baseSize + FelicaLookupResponseStruct.baseSize;
header.result = ResultCodes.SUCCESS;
const req = new FelicaLookupRequestStruct(data);
const resp = new FelicaLookupResponseStruct();
if (!IsSupportedFelica(req.osVer)) {
header.result = ResultCodes.INVALID_AIME_ID;
resp.felicaIndex = -1;
return resp;
}
const table = IsSupportedFelicaMobile(req.osVer) ? felicaMobileLookup : felicaCardLookup;
const result = await db
.select()
.from(table)
.where(eq(table.idm, req.idm.toString(16)))
.then((r) => r[0]);
if (!result) {
resp.felicaIndex = -1;
} else {
resp.felicaIndex = result.id;
resp.accessCode.set(Buffer.from(result.accessCode, "hex"));
}
return resp;
};
export const FelicaExtendedLookupHandler: AimeDBHandlerFn<"FelicaExtendedLookupResponse"> = async (
header,
data
) => {
header.commandId = CommandId.EXTENDED_FELICA_ACCOUNT_RESPONSE;
header.length = PacketHeaderStruct.baseSize + FelicaExtendedLookupResponseStruct.baseSize;
header.result = ResultCodes.SUCCESS;
const req = new FelicaExtendedLookupRequestStruct(data);
const resp = new FelicaExtendedLookupResponseStruct();
resp.accountId = -1;
logger.debug("Parsed response body.", { req });
if (req.companyCode < 0 || req.companyCode > 4) {
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
if (!IsSupportedFelica(req.osVer)) {
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
let result;
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]);
}
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;
resp.accessCode.set(Buffer.from(lookupResult.accessCode, "hex"));
if (cardResult) {
resp.accountId = cardResult.id;
resp.portalRegistered = PortalRegistration.UNREGISTERED;
// 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;
}
return resp;
}
// Assuming that FeliCa Mobile is handled by their own registration endpoint...
if (IsSupportedFelicaMobile(req.osVer)) {
return resp;
}
// 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 }
);
header.result = ResultCodes.INVALID_AIME_ID;
return resp;
}
const mostRecentRow = await db
.select()
.from(felicaCardLookup)
.orderBy(desc(felicaCardLookup.id))
.limit(1)
.then((r) => r[0]);
const nextId = mostRecentRow ? mostRecentRow.id + 1 : 1;
const accessCode = CalculateAccessCode(
nextId,
Config.AIMEDB_CONFIG.RESERVED_CARD_PREFIX,
Config.AIMEDB_CONFIG.RESERVED_CARD_KEY
);
logger.verbose(`Created FeliCa Card access code for serial ${nextId}.`, {
nextId,
accessCode,
});
const value = { idm: req.idm.toString(16), accessCode };
const row = await db
.insert(felicaCardLookup)
.values(value)
.returning()
.then((r) => r[0]);
if (!row) {
logger.crit("Failed to insert new lookup entry into the database.", value);
header.result = ResultCodes.UNKNOWN_ERROR;
return resp;
}
header.result = ResultCodes.SUCCESS;
resp.accessCode.set(Buffer.from(row.accessCode, "hex"));
return resp;
};
export const FelicaRegisterHandler: AimeDBHandlerFn<"FelicaLookupResponse"> = async (
header,
data
) => {
header.commandId = CommandId.FELICA_LOOKUP_RESPONSE;
header.length = PacketHeaderStruct.baseSize + FelicaLookupResponseStruct.baseSize;
const req = new FelicaLookupRequestStruct(data);
const resp = new FelicaLookupResponseStruct();
if (!IsSupportedFelica(req.osVer)) {
logger.verbose("Rejecting card of unknown OS version.", {
req,
});
header.result = ResultCodes.INVALID_AIME_ID;
resp.felicaIndex = -1;
return resp;
}
const result = await db
.select()
.from(felicaMobileLookup)
.where(eq(felicaMobileLookup.idm, req.idm.toString(16)))
.limit(1)
.then((r) => r[0]);
if (result) {
header.result = ResultCodes.ID_ALREADY_REGISTERED;
resp.felicaIndex = result.id;
resp.accessCode.set(Buffer.from(result.accessCode, "hex"));
return resp;
}
if (!Config.AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY) {
logger.error(
"AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY is not set in config file. Cannot register FeliCa Mobile ID.",
{ req }
);
header.result = ResultCodes.INVALID_AIME_ID;
resp.felicaIndex = -1;
return resp;
}
const mostRecentRow = await db
.select()
.from(felicaMobileLookup)
.orderBy(desc(felicaMobileLookup.id))
.limit(1)
.then((r) => r[0]);
const nextId = mostRecentRow ? mostRecentRow.id + 1 : 1;
const accessCode = CalculateAccessCode(
nextId,
"01035",
Config.AIMEDB_CONFIG.AIME_MOBILE_CARD_KEY
);
logger.verbose(`Created FeliCa Mobile access code for serial ${nextId}.`, {
nextId,
accessCode,
});
const value = { idm: req.idm.toString(16), accessCode };
const row = await db
.insert(felicaMobileLookup)
.values(value)
.returning()
.then((r) => r[0]);
if (!row) {
logger.crit("Failed to insert new lookup entry into the database.", value);
header.result = ResultCodes.UNKNOWN_ERROR;
resp.felicaIndex = -1;
return resp;
}
header.result = ResultCodes.SUCCESS;
resp.felicaIndex = row.id;
resp.accessCode.set(Buffer.from(row.accessCode, "hex"));
return resp;
};

View File

@ -0,0 +1,56 @@
import {
GetAimeAccountHandler,
GetAimeAccountExtendedHandler,
RegisterAimeAccountHandler,
} from "./aime-account";
import { AimeExtendedLogHandler, AimeLogHandler, StatusLogHandler } from "./aime-log";
import { GetCampaignClearInfoHandler, GetCampaignInfoHandler } from "./campaign";
import {
FelicaExtendedLookupHandler,
FelicaLookupHandler,
FelicaRegisterHandler,
} from "./felica-conversion";
import { ServiceHealthCheckHandler } from "./status-check";
import { CommandId } from "../utils/misc";
import type { AimeDBHandlerFn, AimeDBReturnTypes } from "../types/handlers";
type CommandIdRequest = {
[K in keyof typeof CommandId]: K extends `${infer _}_REQUEST` ? typeof CommandId[K] : never;
}[keyof typeof CommandId];
// We need to make the key type wider so that the request handler can still index with
// an arbitrary command ID. On the other hand, the `satisfies` clause makes TypeScript
// yells at us for any known unimplemented AimeDB commands.
export const AIMEDB_HANDLERS: Record<number, AimeDBHandlerFn<AimeDBReturnTypes>> = {
[CommandId.FELICA_LOOKUP_REQUEST]: FelicaLookupHandler,
[CommandId.FELICA_REGISTER_REQUEST]: FelicaRegisterHandler,
[CommandId.AIME_ACCOUNT_REQUEST]: GetAimeAccountHandler,
[CommandId.REGISTER_AIME_ACCOUNT_REQUEST]: RegisterAimeAccountHandler,
[CommandId.STATUS_LOG_REQUEST]: StatusLogHandler,
[CommandId.AIME_LOG_REQUEST]: AimeLogHandler,
[CommandId.EXTENDED_AIME_LOG_REQUEST]: AimeExtendedLogHandler,
[CommandId.CAMPAIGN_INFO_REQUEST]: GetCampaignInfoHandler,
[CommandId.CAMPAIGN_CLEAR_INFO_REQUEST]: GetCampaignClearInfoHandler,
[CommandId.EXTENDED_ACCOUNT_REQUEST]: GetAimeAccountExtendedHandler,
[CommandId.EXTENDED_FELICA_ACCOUNT_REQUEST]: FelicaExtendedLookupHandler,
[CommandId.SERVICE_HEALTH_REQUEST]: ServiceHealthCheckHandler,
} satisfies Record<
Exclude<CommandIdRequest, CommandId.CLIENT_END_REQUEST>,
AimeDBHandlerFn<AimeDBReturnTypes>
>;
export const EXPECTED_PACKET_LENGTH: Record<number, number> = {
[CommandId.FELICA_LOOKUP_REQUEST]: 48,
[CommandId.FELICA_REGISTER_REQUEST]: 48,
[CommandId.AIME_ACCOUNT_REQUEST]: 48,
[CommandId.REGISTER_AIME_ACCOUNT_REQUEST]: 48,
[CommandId.STATUS_LOG_REQUEST]: 48,
[CommandId.AIME_LOG_REQUEST]: 64,
[CommandId.EXTENDED_AIME_LOG_REQUEST]: 1008,
[CommandId.CAMPAIGN_INFO_REQUEST]: 48,
[CommandId.CAMPAIGN_CLEAR_INFO_REQUEST]: 48,
[CommandId.EXTENDED_ACCOUNT_REQUEST]: 48,
[CommandId.EXTENDED_FELICA_ACCOUNT_REQUEST]: 112,
[CommandId.SERVICE_HEALTH_REQUEST]: 32,
[CommandId.CLIENT_END_REQUEST]: 32,
} satisfies Record<CommandIdRequest, number>;

View File

@ -0,0 +1,11 @@
import { PacketHeaderStruct } from "../types/header";
import { CommandId, ResultCodes } from "../utils/misc";
import type { AimeDBHandlerFn } from "../types/handlers";
export const ServiceHealthCheckHandler: AimeDBHandlerFn = (header, _) => {
header.result = ResultCodes.SUCCESS;
header.commandId = CommandId.SERVICE_HEALTH_RESPONSE;
header.length = PacketHeaderStruct.baseSize;
return null;
};

178
src/servers/aimedb/index.ts Normal file
View File

@ -0,0 +1,178 @@
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 CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import { createHash } from "crypto";
import type { Socket } from "net";
const logger = CreateLogCtx(__filename);
/**
* For legal reasons, we probably should not include the actual AimeDB key here.
* However, we can still check the key for correctness, so that users get feedback.
*/
const AIMEDB_KEY_SHA256 = "4968f79897cd9517f2b6050a951456106ca98b23535b3e985d62a800b66f3240";
const AimeDBServerFactory = () => {
if (!Config.AIMEDB_CONFIG.KEY) {
throw new Error("AimeDB key not set.");
}
const hash = createHash("sha256");
const digest = hash.update(Config.AIMEDB_CONFIG.KEY).digest("hex");
if (digest !== AIMEDB_KEY_SHA256) {
logger.warn(
"AimeDB key seems incorrect. Allowing it anyways, though games will probably break.",
{
key: Config.AIMEDB_CONFIG.KEY,
expectedSha256: AIMEDB_KEY_SHA256,
}
);
}
const AIMEDB_KEY = Buffer.from(Config.AIMEDB_CONFIG.KEY, "utf-8");
const logResponse = (header: InstanceType<typeof PacketHeaderStruct>, body: unknown) => {
if (header.result !== 1) {
logger.info(`Returned result code ${header.result}.`, {
header,
body,
});
} else {
logger.verbose(`Returned result code ${header.result}.`, {
header,
body,
});
}
};
const writeResponse = (
socket: Socket,
header: InstanceType<typeof PacketHeaderStruct>,
body: unknown
) => {
const headerBuf = PacketHeaderStruct.raw(header);
const chunks = [headerBuf];
// https://github.com/sarakusha/typed-struct/blob/main/src/struct.ts#L799
if (body !== null && typeof body === "object" && "$raw" in body) {
const bodyBuf = (body as { $raw: Buffer }).$raw;
chunks.push(bodyBuf);
}
const raw = Buffer.concat(chunks);
let encrypted: Buffer;
try {
encrypted = encryptPacket(raw, AIMEDB_KEY);
} catch (err) {
logger.error("Could not encrypt AimeDB response.", { err });
return;
}
socket.write(encrypted);
};
return (socket: Socket) => {
socket.on("connect", () => {
logger.debug(`${socket.remoteAddress} connected.`);
});
socket.on("data", async (data) => {
logger.verbose(`Received packet: ${data.toString("hex")}.`);
let packet: Buffer;
try {
packet = decryptPacket(data, AIMEDB_KEY);
} catch (err) {
logger.error("Could not decrypt AimeDB packet.", {
err,
});
return;
}
let header;
try {
header = new PacketHeaderStruct(packet.slice(0, 32));
} catch (err) {
logger.error("Decoding AimeDB header failed.", {
err,
data: packet.toString("hex"),
});
return;
}
logger.debug("Received AimeDB request.", {
header,
data: packet.slice(32).toString("hex"),
});
if (header.magic !== 0xa13e) {
logger.error("Request's magic bytes did not match expected value 0xA13E.", {
header,
});
return;
}
if (header.keychipId === "ABCD1234567" || header.storeId === 0xfff0) {
logger.warning("Received request from uninitialized AMLib.", { header });
}
if (header.commandId === CommandId.CLIENT_END_REQUEST) {
logger.debug("Client ended the session.", { header });
socket.destroy();
return;
}
const expectedLength = EXPECTED_PACKET_LENGTH[header.commandId];
if (!expectedLength) {
logger.warn(
`Packet 0x${header.commandId.toString(
16
)} does not declare an expected size. Allowing all packets.`,
{ header }
);
} else if (expectedLength !== header.length || packet.length !== header.length) {
logger.error("Packet does not have expected size.", {
expectedLength,
actualLength: packet.length,
declaredLength: header.length,
});
header.result = ResultCodes.UNKNOWN_ERROR;
header.length = 32;
logResponse(header, null);
writeResponse(socket, header, null);
return;
}
const handler = AIMEDB_HANDLERS[header.commandId];
if (!handler) {
logger.error("No handlers available for the requested command ID.", { header });
return;
}
const body = await handler(header, packet.slice(32));
logResponse(header, body);
writeResponse(socket, header, body);
});
socket.on("end", () => {
logger.debug(`${socket.remoteAddress} disconnected.`);
});
};
};
export default AimeDBServerFactory;

View File

@ -0,0 +1,30 @@
import Struct from "typed-struct";
export const AimeAccountQueryStruct = new Struct("AimeAccountQuery")
.UInt8Array("accessCode", 10)
.UInt8("companyCode")
.UInt8("firmwareVersion")
.UInt32LE("serialNumber")
.compile();
export const AimeAccountResponseStruct = new Struct("AimeAccountResponse")
.UInt32LE("accountId")
.UInt8("portalRegistered")
.UInt8Array("padding", 11)
.compile();
export const AimeAccountExtendedQueryStruct = new Struct("AimeAccountExtendedQuery")
.UInt8Array("accessCode", 10)
.UInt8("companyCode")
.UInt8("readerFirmwareVersion")
.UInt32LE("cardSerialNumber")
.compile();
export const AimeAccountExtendedResponseStruct = new Struct("AimeAccountExtendedResponse")
.UInt32LE("accountId")
.UInt8("portalRegistered")
.UInt8Array("padding", 3)
.UInt8Array("authKey", 256)
.UInt32LE("relationId1")
.UInt32LE("relationId2")
.compile();

View File

@ -0,0 +1,41 @@
import Struct from "typed-struct";
export const StatusLogStruct = new Struct("StatusLog")
.UInt32LE("aimeId")
.UInt32LE("status")
.UInt8Array("padding", 8)
.compile();
export const AimeLogStruct = new Struct("AimeLog")
.UInt32LE("aimeId")
.UInt32LE("status")
.BigUInt64LE("userId")
.Int32LE("creditCount")
.Int32LE("betCount")
.Int32LE("wonCount")
.UInt8Array("padding", 4)
.compile();
export const ExtendedAimeLogEntryStruct = new Struct("ExtendedAimeLogEntry")
.UInt32LE("aimeId")
.UInt32LE("status")
.BigUInt64LE("userId")
.Int32LE("creditCount")
.Int32LE("betCount")
.Int32LE("wonCount")
.UInt8Array("padding", 4)
.BigUInt64LE("localTime")
.Int32LE("tSeq")
.UInt32LE("placeId")
.compile();
export const ExtendedAimeLogStruct = new Struct("ExtendedAimeLog")
.StructArray("entries", ExtendedAimeLogEntryStruct, 20)
.UInt32LE("count")
.UInt8Array("padding", 12)
.compile();
export const AimeLogExtendedResponseStruct = new Struct("AimeLogExtendedResponse")
.UInt8Array("result", 20)
.UInt8Array("padding", 12)
.compile();

View File

@ -0,0 +1,47 @@
import Struct from "typed-struct";
/**
* Retrieve information regarding currently active campaigns.
*/
export const OldCampaignRequestStruct = new Struct("OldCampaignRequest")
.UInt32LE("campaignId")
.UInt8Array("padding", 12)
.compile();
export const OldCampaignResponseStruct = new Struct("OldCampaignResponse")
.Int32LE("info0")
.Int32LE("info1")
.Int32LE("info2")
.Int32LE("info3")
.compile();
const CampaignStruct = new Struct("Campaign")
.UInt32LE("campaignId")
.String("campaignName", 128)
.UInt32LE("announceDate")
.UInt32LE("startDate")
.UInt32LE("endDate")
.UInt32LE("distributionStartDate")
.UInt32LE("distributionEndDate")
.UInt8Array("padding", 8)
.compile();
export const CampaignResponseStruct = new Struct("CampaignResponse")
.StructArray("campaigns", CampaignStruct, 3)
.compile();
export const CampaignClearInfoRequestStruct = new Struct("CampaignClearInfoRequest")
.UInt32LE("aimeId")
.UInt8Array("padding", 12)
.compile();
export const CampaignClearInfoStruct = new Struct("CampaignClearInfo")
.UInt32LE("campaignId")
.UInt32LE("entryFlag")
.UInt32LE("clearFlag")
.UInt8Array("padding", 4)
.compile();
export const CampaignClearInfoResponseStruct = new Struct("CampaignClearInfoResponse")
.StructArray("clearInfos", CampaignClearInfoStruct, 3)
.compile();

View File

@ -0,0 +1,40 @@
import Struct from "typed-struct";
export const FelicaLookupRequestStruct = new Struct("FelicaLookupRequest")
.BigUInt64LE("idm")
.UInt8("chipCode")
.UInt8("osVer")
.UInt8Array("timing", 6)
.compile();
export const FelicaLookupResponseStruct = new Struct("FelicaLookupResponse")
.UInt32LE("felicaIndex")
.UInt8Array("accessCode", 10)
.UInt16LE("padding")
.compile();
export const FelicaExtendedLookupRequestStruct = new Struct("FelicaExtendedLookupRequest")
.UInt8Array("randomChallenge", 16)
.BigUInt64LE("idm")
.UInt8("chipCode")
.UInt8("osVer")
.UInt8Array("timing", 6)
.UInt8Array("cardKeyVersion", 16)
.UInt8Array("writeCount", 16)
.BigUInt64LE("maca")
.UInt8("companyCode")
.UInt8("readerFirmwareVersion")
.UInt16LE("DFC")
.UInt8Array("padding", 4)
.compile();
export const FelicaExtendedLookupResponseStruct = new Struct("FelicaExtendedLookupResponse")
.UInt32LE("accountId")
.UInt32LE("relationId1")
.UInt32LE("relationId2")
.UInt8Array("accessCode", 10)
.UInt8("portalRegistered")
.UInt8("companyCode")
.UInt8Array("padding", 8)
.UInt8Array("authKey", 256)
.compile();

View File

@ -0,0 +1,31 @@
import type { PacketHeaderStruct } from "./header";
import type { StructConstructor } from "typed-struct";
export type AimeDBHandlerReturnType<S extends string> = InstanceType<
StructConstructor<Record<string, unknown>, S>
>;
export type AimeDBReturnTypes =
| "__no_body"
| "AimeAccountExtendedResponse"
| "AimeAccountResponse"
| "AimeLogExtendedResponse"
| "CampaignClearInfoResponse"
| "CampaignResponse"
| "FelicaExtendedLookupResponse"
| "FelicaLookupResponse"
| "OldCampaignResponse";
type MaybePromise<T> = Promise<T> | T;
/**
* Base type for all AimeDB command handlers.
*
* All AimeDB handlers must modify the request header and only return the request body.
* The base handler will merge them into one.
*/
export type AimeDBHandlerFn<S extends AimeDBReturnTypes = "__no_body"> = (
header: InstanceType<typeof PacketHeaderStruct>,
data: Buffer
) => MaybePromise<(S extends "__no_body" ? never : AimeDBHandlerReturnType<S>) | null | undefined>;

View File

@ -0,0 +1,12 @@
import Struct from "typed-struct";
export const PacketHeaderStruct = new Struct("PacketHeader")
.UInt16LE("magic")
.UInt16LE("version")
.UInt16LE("commandId")
.UInt16LE("length")
.UInt16LE("result")
.String("gameId", 6)
.UInt32LE("storeId")
.String("keychipId", 12)
.compile();

View File

@ -0,0 +1,53 @@
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) {
const paddedSerial = serial.toString().padStart(8, "0");
const realDigest = createHash("md5").update(paddedSerial).digest();
const digest = new Array(16);
for (let i = 0; i < 16; i++) {
const idx = Number(`0x${key[i]}`);
const nib = realDigest[idx];
if (nib === undefined) {
throw new Error(
"crypto.createHash returned an undefined value in a Buffer. this should not happen."
);
}
digest[i] = nib;
}
// 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");
}
export function CalculateAccessCode(serial: integer, prefix: string, key: string): string {
const digest = CalculateCardKey(serial, key);
const paddedSerial = serial.toString().padStart(8, "0");
const hashedId = Solitaire.encrypt(paddedSerial, digest);
return `${prefix}${hashedId}${digest}`;
}

View File

@ -0,0 +1,230 @@
/* eslint-disable no-bitwise */
import { createCipheriv, createDecipheriv } from "crypto";
import type { integer } from "types/misc";
const ASCII_0 = 48;
export function encryptPacket(packet: Buffer, key: Buffer) {
const cipher = createCipheriv("aes-128-ecb", key, null).setAutoPadding(false);
return Buffer.concat([cipher.update(packet), cipher.final()]);
}
export function decryptPacket(packet: Buffer, key: Buffer) {
const cipher = createDecipheriv("aes-128-ecb", key, null).setAutoPadding(false);
return Buffer.concat([cipher.update(packet), cipher.final()]);
}
function char2num(char: string) {
return char.charCodeAt(0) - ASCII_0 + 1;
}
function num2char(num: integer) {
let inner = num;
while (inner < 1) {
inner = inner + 10;
}
// 48 is ASCII 0
return String.fromCharCode(((inner - 1) % 10) + ASCII_0);
}
/**
* A modified version of the [Solitaire cipher](https://en.wikipedia.org/wiki/Solitaire_(cipher)),
* used for encrypting and decrypting access codes.
*/
export class Solitaire {
private deck: Array<integer>;
private readonly deckSize: integer;
readonly jokerA: integer;
readonly jokerB: integer;
constructor(deckSize: integer, jokerA?: integer, jokerB?: integer) {
if (jokerA && jokerA > deckSize) {
throw new Error(
"Invalid jokerA. jokerA must not be larger than the largest card on deck."
);
}
if (jokerB && jokerB > deckSize) {
throw new Error(
"Invalid jokerB. jokerB must not be larger than the largest card on deck."
);
}
this.jokerA = jokerA ?? deckSize - 1;
this.jokerB = jokerB ?? deckSize;
this.deck = [...Array(deckSize).keys()].map((v) => v + 1);
this.deckSize = deckSize;
}
static encrypt(src: string, key: string) {
const deck = new Solitaire(22, 21, 22);
for (const char of key) {
deck.deckHash();
deck.cutDeck(char2num(char));
}
let output = "";
for (const char of src) {
deck.deckHash();
const p = deck.getTopCardNumber() ?? 0;
output = output + num2char(char2num(char) + p);
}
return output;
}
static decrypt(src: string, key: string) {
const deck = new Solitaire(22, 21, 22);
for (const char of key) {
deck.deckHash();
deck.cutDeck(char2num(char));
}
let output = "";
for (const char of src) {
deck.deckHash();
const p = deck.getTopCardNumber() ?? 0;
output = output + num2char(char2num(char) - p);
}
return output;
}
private moveCardDown(card: integer) {
const cardLocation = this.deck.indexOf(card);
if (cardLocation === -1) {
throw new Error("Card was not in the deck.");
}
if (cardLocation < this.deckSize - 1) {
const nextCard = this.deck[cardLocation + 1];
if (!nextCard) {
throw new Error("Next card is undefined. This should not be possible.");
}
this.deck[cardLocation] = nextCard;
this.deck[cardLocation + 1] = card;
} else {
for (let i = this.deckSize - 1; i > 1; i--) {
const prevCard = this.deck[i - 1];
if (!prevCard) {
throw new Error("Previous card is undefined. This should not be possible.");
}
this.deck[i] = prevCard;
}
this.deck[1] = card;
}
}
private cutDeck(point: integer) {
const tmpDeck = this.deck.slice(0);
let idx = 0;
for (let i = point; i < this.deckSize - 1; i++) {
const item = tmpDeck[i];
if (!item) {
throw new Error("not happening.");
}
this.deck[idx] = item;
idx++;
}
for (let i = 0; i < point; i++) {
const item = tmpDeck[i];
if (!item) {
throw new Error("not happening.");
}
this.deck[idx] = item;
idx++;
}
}
private swapOutsideJoker() {
let joker1 = -1;
let joker1Value = -1;
let joker2 = -1;
let joker2Value = -1;
for (let i = 0; i < this.deckSize; i++) {
const card = this.deck[i];
if (!card) {
continue;
}
if (card === this.jokerA || card === this.jokerB) {
if (joker1 === -1) {
joker1 = i;
joker1Value = card;
} else {
joker2 = i;
joker2Value = card;
}
}
}
this.deck = [
...this.deck.slice(joker2 + 1),
joker1Value,
...this.deck.slice(joker1 + 1, joker2),
joker2Value,
...this.deck.slice(0, joker1),
];
}
private cutByBottomCard() {
const bottom = this.deck[this.deckSize - 1];
if (!bottom) {
throw new Error("bottom card is undefined (not possbile)");
}
this.cutDeck(bottom === this.jokerB ? this.jokerA : bottom);
}
private getTopCardNumber() {
const top = this.deck[0];
if (!top) {
throw new Error("top card is undefined (not possbile)");
}
return this.deck[top === this.jokerB ? this.jokerA : top];
}
private deckHash() {
let p = -1;
do {
this.moveCardDown(this.jokerA);
this.moveCardDown(this.jokerB);
this.moveCardDown(this.jokerB);
this.swapOutsideJoker();
this.cutByBottomCard();
p = this.getTopCardNumber() ?? -1;
} while (p === this.jokerA || p === this.jokerB);
}
}

View File

@ -0,0 +1,20 @@
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;
export function IsSupportedFelicaMobile(
osVer: number
): osVer is typeof FELICA_MOBILE_OS_VERSIONS[number] {
return (FELICA_MOBILE_OS_VERSIONS as ReadonlyArray<number>).includes(osVer);
}
export function IsSupportedFelicaCard(
osVer: number
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] {
return (FELICA_CARD_OS_VERSIONS as ReadonlyArray<number>).includes(osVer);
}
export function IsSupportedFelica(
osVer: number
): osVer is typeof FELICA_CARD_OS_VERSIONS[number] | typeof FELICA_MOBILE_OS_VERSIONS[number] {
return IsSupportedFelicaMobile(osVer) || IsSupportedFelicaCard(osVer);
}

View File

@ -0,0 +1,70 @@
export const enum ResultCodes {
UNKNOWN_ERROR = 0,
SUCCESS = 1,
INVALID_AIME_ID = 2,
ID_ALREADY_REGISTERED = 3,
BAN_SYSTEM_AND_USER_LOCK = 4,
BAN_SYSTEM_LOCK = 5,
BAN_USER_LOCK = 6,
BAN_GENERIC_LOCK = 7,
SYSTEM_AND_USER_LOCK = 8,
SYSTEM_LOCK = 9,
USER_LOCK = 10,
}
export const enum CommandId {
FELICA_LOOKUP_REQUEST = 1,
FELICA_REGISTER_REQUEST = 2,
FELICA_LOOKUP_RESPONSE = 3,
AIME_ACCOUNT_REQUEST = 4,
REGISTER_AIME_ACCOUNT_REQUEST = 5,
AIME_ACCOUNT_RESPONSE = 6,
STATUS_LOG_REQUEST = 7,
STATUS_LOG_RESPONSE = 8,
AIME_LOG_REQUEST = 9,
AIME_LOG_RESPONSE = 10,
CAMPAIGN_INFO_REQUEST = 11,
CAMPAIGN_INFO_RESPONSE = 12,
CAMPAIGN_CLEAR_INFO_REQUEST = 13,
CAMPAIGN_CLEAR_INFO_RESPONSE = 14,
EXTENDED_ACCOUNT_REQUEST = 15,
EXTENDED_ACCOUNT_RESPONSE = 16,
EXTENDED_FELICA_ACCOUNT_REQUEST = 17,
EXTENDED_FELICA_ACCOUNT_RESPONSE = 18,
EXTENDED_AIME_LOG_REQUEST = 19,
EXTENDED_AIME_LOG_RESPONSE = 20,
SERVICE_HEALTH_REQUEST = 100,
SERVICE_HEALTH_RESPONSE = 101,
CLIENT_END_REQUEST = 102,
}
export enum LogStatus {
NONE = 0,
GAME_START = 1,
GAME_CONTINUE = 2,
GAME_END = 3,
OTHERS = 4,
}
export const enum PortalRegistration {
UNREGISTERED = 0,
REGISTERED_ON_PORTAL = 1,
LINKED_TO_ID = 2,
}
export const enum CompanyCodes {
SEGA = 1,
BANDAI_NAMCO = 2,
KONAMI = 3,
TAITO = 4,
}

View File

@ -0,0 +1,77 @@
// 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";
// 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 { IsRecord } from "utils/misc";
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 });
});
// 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);
app.use((req, res, next) => {
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
req.body = {};
}
req.safeBody = req.body as Record<string, unknown>;
next();
});
app.use(RequestLoggerMiddleware);
app.use("/", mainRouter);
const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => {
logger.info(`MAIN_ERR_HANDLER hit by request.`, { url: req.originalUrl });
const unknownErr = err as unknown;
if (IsRecord(unknownErr) && unknownErr.type === "entity.too.large") {
return res.status(413).json({
success: false,
description: "Your request body was too large. The limit is 4MB.",
});
}
logger.error("Fatal error propagated to server root? ", {
err: unknownErr,
url: req.originalUrl,
});
return res.status(500).json({
success: false,
description: "A fatal internal server error has occured.",
});
};
app.use(MAIN_ERR_HANDLER);
export default app;

View File

@ -0,0 +1,64 @@
import iconv from "iconv-lite";
import getRawBody from "raw-body";
import { promisify } from "util";
import { inflate } from "zlib";
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) {
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,56 @@
import CreateLogCtx from "lib/logger/logger";
import type { RequestHandler, Response } from "express";
const logger = CreateLogCtx(__filename);
// https://stackoverflow.com/a/64546368/11885828
const ResSendInterceptor = (res: Response, send: Response["send"]) => (content: unknown) => {
// @ts-expect-error general monkeypatching error
res.contentBody = content;
res.send = send;
res.send(content);
};
export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => {
logger.debug(`Received request ${req.method} ${req.originalUrl}.`, {
query: req.query,
body: req.body,
});
// @ts-expect-error we're doing some wacky monkey patching
res.send = ResSendInterceptor(res, res.send);
res.on("finish", () => {
const contents = {
// @ts-expect-error we're doing some monkey patching - contentBody is what we're returning.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
body: res.contentBody,
statusCode: res.statusCode,
requestQuery: req.query,
requestBody: req.body,
fromIp: req.ip,
};
// special overrides
// This stuff is spam, so we'll just not log it.
if (res.statusCode === 429) {
return;
}
if (res.statusCode < 400 || res.statusCode === 404) {
logger.verbose(
`(${req.method} ${req.originalUrl}) Returned ${res.statusCode}.`,
contents
);
} else if (res.statusCode < 500) {
logger.info(`(${req.method} ${req.originalUrl}) Returned ${res.statusCode}.`, contents);
} else {
logger.error(
`(${req.method} ${req.originalUrl}) Returned ${res.statusCode}.`,
contents
);
}
});
next();
};

View File

@ -0,0 +1,12 @@
import sysServletRouter from "./sys/servlet/router";
import { Router } from "express";
const router: Router = Router({ mergeParams: true });
router.all("/naomitest.html", (_, res) => {
return res.status(200).send("naomi ok").contentType("text/html");
});
router.use("/sys/servlet", sysServletRouter);
export default router;

View File

@ -0,0 +1,13 @@
import { Router } from "express";
import iconv from "iconv-lite";
const router: Router = Router({ mergeParams: true });
router.all("/", (_, res) => {
return res
.status(200)
.contentType("text/plain; charset=EUC-JP")
.send(iconv.encode("OK", "EUC-JP"));
});
export default router;

View File

@ -0,0 +1,25 @@
import { Router } from "express";
import {
DownloadOrderRequestSchema,
DownloadOrderStatus,
} from "servers/allnet/types/download-order";
import type { DownloadOrderResponse } from "servers/allnet/types/download-order";
const router: Router = Router({ mergeParams: true });
router.post("/", (req, res) => {
const parseResult = DownloadOrderRequestSchema.safeParse(req.safeBody);
if (!parseResult.success) {
return res.status(403).send("");
}
// TODO: Allow network delivery.
const response = {
stat: DownloadOrderStatus.FAILURE,
} satisfies DownloadOrderResponse;
return res.status(200).send(response);
});
export default router;

View File

@ -0,0 +1,226 @@
import { eq } from "drizzle-orm";
import { Router } from "express";
import { db } from "external/db/db";
import { arcade, machine } from "external/db/schemas";
import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import { DateTime } from "luxon";
import {
PowerOnRequestChinaSchema,
PowerOnRequestV1Schema,
PowerOnRequestV2Schema,
PowerOnRequestV3Schema,
PowerOnRequestV5Schema,
PowerOnRequestV6Schema,
PowerOnStat,
} from "servers/allnet/types/power-on";
import { fromZodError } from "zod-validation-error";
import type {
PowerOnResponseChina,
PowerOnResponseV1,
PowerOnResponseV2,
PowerOnResponseV3,
PowerOnResponseV5,
} from "servers/allnet/types/power-on";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true });
router.post("/", async (req, res) => {
if (!req.ip) {
logger.error("Request does not have an IP associated with it?");
return res.status(500).send();
}
const formatVer = req.safeBody.format_ver ?? "1";
let parseResult;
// TODO: ALL.Net China
if (formatVer === "1") {
parseResult = PowerOnRequestV1Schema.safeParse(req.safeBody);
} else if (formatVer === "2") {
parseResult = PowerOnRequestV2Schema.safeParse(req.safeBody);
} else if (formatVer === "3") {
parseResult =
"game_ver" in req.safeBody
? PowerOnRequestChinaSchema.safeParse(req.safeBody)
: PowerOnRequestV3Schema.safeParse(req.safeBody);
} else if (formatVer === "5") {
parseResult = PowerOnRequestV5Schema.safeParse(req.safeBody);
} else if (formatVer === "6") {
parseResult = PowerOnRequestV6Schema.safeParse(req.safeBody);
} else {
logger.error("Received PowerOn request of unknown version.", { formatVer });
return res.status(400).send("");
}
if (!parseResult.success) {
logger.error(`Received invalid PowerOn request: ${fromZodError(parseResult.error)}`, {
formatVer,
});
return res.status(400).send("");
}
const request = parseResult.data;
const gameId = "game_id" in request ? request.game_id : request.title_id;
const serial = "serial" in request ? request.serial : request.machine;
const titleVer =
// eslint-disable-next-line no-nested-ternary
"ver" in request
? request.ver
: "game_ver" in request
? request.game_ver
: request.title_ver;
// We reject non-DFI requests late, so we can find the offending
// "machine".
if (req.headers.pragma?.toUpperCase() !== "DFI") {
logger.error("Received non-DFI-encoded request.", {
gameId,
serial,
ip: req.ip,
});
return res.status(400).send("");
}
if (titleVer.length === 0) {
logger.error("Received PowerOn request with empty title version.", {
formatVer,
titleVer,
});
return res.status(400).send("");
}
// 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]);
if (!cabAndStore && !Config.ALLNET_CONFIG.ALLOW_UNREGISTERED_SERIALS) {
logger.error("Unregistered serial attempted ALL.Net authentication.", {
gameId,
serial,
ip: req.ip,
});
return res.status(400).send({ stat: PowerOnStat.BOARD_ERROR });
}
// 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),
// 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",
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 ?? "",
};
let response;
if (request.format_ver === "1") {
response = {
...baseResponse,
year: serverTime.year,
month: serverTime.month,
day: serverTime.day,
hour: serverTime.hour,
minute: serverTime.minute,
second: serverTime.second,
} satisfies PowerOnResponseV1;
} else if (request.format_ver === "2") {
response = {
...baseResponse,
year: serverTime.year,
month: serverTime.month,
day: serverTime.day,
hour: serverTime.hour,
minute: serverTime.minute,
second: serverTime.second,
country: "JPN",
timezone: "+09:00",
res_class: "PowerOnResponseVer2",
} satisfies PowerOnResponseV2;
} else if (request.format_ver === "3" && "ver" in request) {
response = {
...baseResponse,
res_ver: 3,
allnet_id: 456,
utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
country: "JPN",
client_timezone: "+0900",
token: request.token ?? "null",
} satisfies PowerOnResponseV3;
} else if (request.format_ver === "3" && "game_ver" in request) {
response = {
result: baseResponse.stat,
place_id: baseResponse.place_id,
uri1: baseResponse.uri,
uri2: baseResponse.uri,
name: baseResponse.name,
nickname: baseResponse.nickname,
setting: baseResponse.setting,
region0: baseResponse.region0,
region_name0: baseResponse.region_name0,
region_name1: baseResponse.region_name1,
region_name2: baseResponse.region_name2,
region_name3: baseResponse.region_name3,
country: "CHN",
client_timezone: "+800",
token: request.token ?? "null",
res_ver: 3,
allnet_id: 456,
utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
} satisfies PowerOnResponseChina;
} else if (request.format_ver === "5") {
response = {
result: baseResponse.stat,
place_id: baseResponse.place_id,
title_uri: baseResponse.uri,
title_host: baseResponse.host,
name: baseResponse.name,
nickname: baseResponse.nickname,
setting: baseResponse.setting,
region0: baseResponse.region0,
region_name0: baseResponse.region_name0,
region_name1: baseResponse.region_name1,
region_name2: baseResponse.region_name2,
region_name3: baseResponse.region_name3,
res_ver: 5,
allnet_id: 456,
utc_time: serverTime.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
country: "JPN",
client_timezone: "+0900",
token: request.token ?? "null",
} satisfies PowerOnResponseV5;
} else {
logger.error("Received auth20 PowerOn request, which is currently unsupported.", {
gameId,
serial,
ip: req.ip,
});
return res.status(403).send({ stat: 0 });
}
logger.info("Authenticated machine with ALL.Net.", { serial, gameId, ip: req.ip });
return res.status(200).send(response);
});
export default router;

View File

@ -0,0 +1,12 @@
import aliveRouter from "./Alive/router";
import downloadOrderRouter from "./DownloadOrder/router";
import powerOnRouter from "./PowerOn/router";
import { Router } from "express";
const router: Router = Router({ mergeParams: true });
router.use("/Alive", aliveRouter);
router.use("/DownloadOrder", downloadOrderRouter);
router.use("/PowerOn", powerOnRouter);
export default router;

View File

@ -0,0 +1,22 @@
import { z } from "zod";
export const DownloadOrderRequestSchema = z.object({
game_id: z.string().max(5),
ver: z.string().max(5),
serial: z.string().max(11),
ip: z.string().ip().optional(),
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
});
export type DownloadOrderRequest = z.infer<typeof DownloadOrderRequestSchema>;
export const enum DownloadOrderStatus {
FAILURE = 0,
SUCCESS = 1,
}
export interface DownloadOrderResponse {
stat: DownloadOrderStatus;
serial?: string;
uri?: string;
}

View File

@ -0,0 +1,278 @@
import { z } from "zod";
import type { integer } from "types/misc";
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(),
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
format_ver: z.literal("1"),
hops: z.coerce.number().default(-1),
});
export type PowerOnRequestV1 = z.infer<typeof PowerOnRequestV1Schema>;
export const PowerOnRequestV2Schema = PowerOnRequestV1Schema.extend({
format_ver: z.literal("2"),
});
export type PowerOnRequestV2 = z.infer<typeof PowerOnRequestV2Schema>;
export const PowerOnRequestV3Schema = PowerOnRequestV2Schema.extend({
format_ver: z.literal("3"),
token: z.string().optional(),
});
export type PowerOnRequestV3 = z.infer<typeof PowerOnRequestV3Schema>;
export const PowerOnRequestV5Schema = z.object({
title_id: z.string().max(5),
title_ver: z.string().max(5),
machine: z.string().max(11),
firm_ver: z.coerce.number().optional(),
boot_ver: z.coerce.number().optional(),
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
format_ver: z.literal("5"),
hops: z.coerce.number().default(-1),
token: z.string().optional(),
});
export type PowerOnRequestV5 = z.infer<typeof PowerOnRequestV5Schema>;
export const PowerOnRequestChinaSchema = z.object({
game_id: z.string().max(5),
game_ver: z.string().max(5),
machine: z.string().max(11),
server: z.string(),
firm_ver: z.coerce.number().optional(),
boot_ver: z.coerce.number().optional(),
encode: z.enum(["Shift_JIS", "EUC-JP", "UTF-8"]).default("EUC-JP"),
format_ver: z.literal("3"),
hops: z.coerce.number().default(-1),
token: z.string().optional(),
});
export type PowerOnRequestChina = z.infer<typeof PowerOnRequestChinaSchema>;
export const PowerOnRequestV6Schema = PowerOnRequestV3Schema.extend({
format_ver: z.literal("6"),
auth_data: z.string().max(1535),
});
export type PowerOnRequestV6 = z.infer<typeof PowerOnRequestV6Schema>;
export const enum PowerOnStat {
SUCCESS = 1,
GAME_ERROR = -1,
BOARD_ERROR = -2,
LOCATION_ERROR = -3,
}
export interface PowerOnResponseV1 {
/**
* ALL.Net authentication status
*/
stat: PowerOnStat;
/**
* The arcade's place ID, encoded as an uppercase hexadecimal string.
*/
place_id: string;
/**
* Title server URI. Will be present, but empty, if authentication
* is not successful.
*/
uri: string;
/**
* Title server hostname. Will be present, but empty, if authentication
* is not successful.
*
* @note The hostname (if present) in URI is only used for name resolution.
* This value is passed to the title server in the `Host` header, and can be
* utilized as an extra authentication step.
*/
host: string;
/**
* ALL.Net location name
*
* @note URL-encoded UTF-8.
*/
name: string;
/**
* ALL.Net location nickname
*
* @note URL-encoded UTF-8.
*/
nickname: string;
/**
* Server time's year
*/
year: integer;
/**
* Server time's month
*/
month: integer;
/**
* Server time's day
*/
day: integer;
/**
* Server time's hour
*/
hour: integer;
/**
* Server time's minute
*/
minute: integer;
/**
* Server time's second
*/
second: integer;
/**
* Game-specific setting.
*
* @note In practice this appears to be used as a "network allowed" flag.
* If ever unsure, specify this field as `1`.
*/
setting: integer;
/**
* Region code
*/
region0: integer;
/**
* Region 1
*
* @note URL-encoded UTF-8.
*/
region_name0: string;
/**
* Region 2
*
* @note URL-encoded UTF-8.
*/
region_name1: string;
/**
* Region 3
*
* @note URL-encoded UTF-8.
*/
region_name2: string;
/**
* Region 4
*
* @note URL-encoded UTF-8.
*/
region_name3: string;
}
export interface PowerOnResponseV2 extends PowerOnResponseV1 {
/**
* Country code
*/
country: string;
timezone: "+09:00";
res_class: "PowerOnResponseVer2";
}
export interface PowerOnResponseV3
extends Omit<
PowerOnResponseV2,
"day" | "hour" | "minute" | "month" | "res_class" | "second" | "timezone" | "year"
> {
res_ver: 3;
/**
* ALL.Net ID
*/
allnet_id: integer;
/**
* Authentication time. Format must be `yyyy-MM-dd'T'HH:mm:ss'Z'`.
*/
utc_time: string;
/**
* @example `+0900`
*/
client_timezone: string;
/**
* Echoes the request token. The literal `null` will be used if token is not
* present in request.
*/
token: string;
}
export interface PowerOnResponseV5
extends Omit<PowerOnResponseV3, "host" | "res_ver" | "stat" | "uri"> {
res_ver: 5;
/**
* ALL.Net authentication status
*/
result: PowerOnStat;
/**
* Title server URI. Will be present, but empty, if authentication
* is not successful.
*/
title_uri: string;
/**
* Title server hostname. Will be present, but empty, if authentication
* is not successful.
*
* @note The hostname (if present) in URI is only used for name resolution.
* This value is passed to the title server in the `Host` header, and can be
* utilized as an extra authentication step.
*/
title_host: string;
}
export interface PowerOnResponseChina
extends Omit<PowerOnResponseV3, "client_timezone" | "country" | "host" | "stat" | "uri"> {
/**
* ALL.Net authentication status
*/
result: PowerOnStat;
/**
* Title server URI 1
*/
uri1: string;
/**
* Title server URI 2
*/
uri2: string;
country: "CHN";
client_timezone: "+800";
}
export interface PowerOnResponseV6 {
auth_data: string;
packet_data: string;
}

2
src/servers/index.ts Normal file
View File

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

1
src/types/misc.ts Normal file
View File

@ -0,0 +1 @@
export type integer = number;

13
src/utils/misc.ts Normal file
View File

@ -0,0 +1,13 @@
export function EscapeStringRegexp(string: string) {
if (typeof string !== "string") {
throw new TypeError("Expected a string");
}
// Escape characters with special meaning either inside or outside character sets.
// Use a simple backslash escape when it's always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns' stricter grammar.
return string.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&").replace(/-/gu, "\\x2d");
}
export function IsRecord(maybeRecord: unknown): maybeRecord is Record<string, unknown> {
return typeof maybeRecord === "object" && maybeRecord !== null;
}

15
tsconfig.build.json Normal file
View File

@ -0,0 +1,15 @@
// This TSConfig file is used when pnpm build is ran. It omits out
// unecessary files, such as anything involved in testing.
// This is a separate TSConfig file because VSCode (by default) uses
// the exclude list to find out what it should care about for typechecking
// while writing code.
// Since I still want my tests to take advantage of static typing, this is
// a necessary hack.
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"src/test-utils",
"src/**/*.test.ts"
]
}

110
tsconfig.json Normal file
View File

@ -0,0 +1,110 @@
{
"include": ["src"],
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
"tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"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. */
// "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*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": ["@types", "node_modules/@types"], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": false, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./js", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* 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'. */
// "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. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
"noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}