initial commit

This commit is contained in:
GEEKiDoS
2024-01-08 19:51:09 +08:00
parent a87182e94e
commit 4fd4c5c176
22 changed files with 2096 additions and 92 deletions
-42
View File
@@ -1,42 +0,0 @@
import { Delays, greeter } from '../src/main.js';
describe('greeter function', () => {
const name = 'John';
let hello: string;
let timeoutSpy: jest.SpyInstance;
// Act before assertions
beforeAll(async () => {
// Read more about fake timers
// http://facebook.github.io/jest/docs/en/timer-mocks.html#content
// Jest 27 now uses "modern" implementation of fake timers
// https://jestjs.io/blog/2021/05/25/jest-27#flipping-defaults
// https://github.com/facebook/jest/pull/5171
jest.useFakeTimers();
timeoutSpy = jest.spyOn(global, 'setTimeout');
const p: Promise<string> = greeter(name);
jest.runOnlyPendingTimers();
hello = await p;
});
// Teardown (cleanup) after assertions
afterAll(() => {
timeoutSpy.mockRestore();
});
// Assert if setTimeout was called properly
it('delays the greeting by 2 seconds', () => {
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenLastCalledWith(
expect.any(Function),
Delays.Long,
);
});
// Assert greeter result
it('greets a user with `Hello, {name}` message', () => {
expect(hello).toBe(`Hello, ${name}`);
});
});
+1564 -20
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -8,7 +8,9 @@
},
"devDependencies": {
"@types/jest": "~29.5",
"@types/koa": "^2.13.12",
"@types/node": "~20",
"@types/pug": "^2.0.10",
"@typescript-eslint/eslint-plugin": "~6.15",
"@typescript-eslint/parser": "~6.15",
"eslint": "~8.56",
@@ -19,10 +21,12 @@
"rimraf": "~5.0",
"ts-api-utils": "~1.0",
"ts-jest": "~29.1",
"tsx": "^4.7.0",
"typescript": "~5.3"
},
"scripts": {
"start": "node build/src/main.js",
"watch": "tsx watch src/main.ts",
"clean": "rimraf coverage build tmp",
"prebuild": "npm run lint",
"build": "tsc -p tsconfig.json",
@@ -36,7 +40,16 @@
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
"license": "Apache-2.0",
"dependencies": {
"tslib": "~2.6"
"@cordisjs/logger": "^0.1.3",
"@kamyu/kbinxml": "^2.0.2",
"arc4": "^3.4.0",
"dedent-js": "^1.0.1",
"fast-xml-parser": "^4.3.2",
"koa": "^2.15.0",
"pug": "^3.0.2",
"reflect-metadata": "^0.2.1",
"tslib": "~2.6",
"tsyringe": "^4.8.0"
},
"volta": {
"node": "20.10.0"
+18
View File
@@ -0,0 +1,18 @@
import pug from 'pug';
import { to_bin } from "@kamyu/kbinxml";
export function kxml(templatePath: string): MethodDecorator {
const template = pug.compileFile('templates/' + templatePath + '.pug');
return function (_target, _propertyKey, descriptor) {
const orig = descriptor.value as Function;
descriptor.value = async function (...args: any[]) {
const obj = await orig.apply(this, args);
const xml = '<?xml version="1.0" encoding="SHIFT_JIS"?>' + template(obj);
console.log(xml);
const kxml = to_bin(xml);
return Buffer.from(kxml.data);
} as unknown as any;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { singleton } from "tsyringe";
import { Context, ILaochanService } from "../types.js";
import dedent from 'dedent-js'
import { to_bin } from "@kamyu/kbinxml";
@singleton()
export class DefaultService implements ILaochanService {
async process(ctx: Context) {
const tmp = ctx.service.split('.');
const module = tmp[0];
const method = tmp[1];
const xml = dedent`
<?xml version="1.0" encoding="SHIFT_JIS"?>
<response>
<${module} method="${method}" status="0"/>
</response>
`;
return Buffer.from(to_bin(xml).data);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { kxml } from "../../decorators/render-kxml.js";
import { ILaochanService } from "../../types.js";
import { singleton } from "tsyringe";
@singleton()
export default class implements ILaochanService {
@kxml('facility/get')
async process() {
return {
status: 0,
location: {
id: 'EA000000',
type: 0,
country: 'JP',
region: 'JP-0',
name: 'LaoChan Test',
countryName: 'LaoChan',
countryJName: '老禅',
regionName: 'BianYu',
regionJName: '變魚',
customerCode: 'LaoChan',
companyCode: 'LaoChan',
latitude: 0,
longitude: 0,
accuracy: 0,
},
line: {
id: '0',
class: 8,
upClass: 8,
rtt: 22,
},
portfw: {
globalIp: '127.0.0.1',
globalport: 5700,
privateport: 5700,
},
public: {
flag: 0,
name: 'LaoChan Test',
latitude: '0',
longitude: '0',
},
share: {
url: '老禅 LaoChan LaoChan 老禅',
eapass: 365,
eacoin: {
notchAmount: 0,
notchCount: 0,
supplyLimit: 100000,
}
},
calendar: {
year: 2024,
holiday: [0, 1, 8, 41, 53, 79, 118, 122, 123, 124, 197, 222, 260, 265, 281, 306, 326, 365, 372, 406, 407, 418, 444, 484, 488, 489, 490, 491, 561, 588, 589, 624, 630, 631, 652, 672, 673, 692],
}
};
}
}
+14
View File
@@ -0,0 +1,14 @@
import { kxml } from "../../decorators/render-kxml.js";
import { ILaochanService } from "../../types.js";
import { singleton } from "tsyringe";
@singleton()
export default class implements ILaochanService {
@kxml('message/get')
async process() {
return {
status: 0,
expire: 60,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { kxml } from "../../decorators/render-kxml.js";
import { ILaochanService } from "../../types.js";
import { singleton } from "tsyringe";
import { DefaultService } from "../default.js";
@singleton()
export default class extends DefaultService {}
+16
View File
@@ -0,0 +1,16 @@
import { kxml } from "../../decorators/render-kxml.js";
import { ILaochanService } from "../../types.js";
import { singleton } from "tsyringe";
@singleton()
export default class implements ILaochanService {
@kxml('pcbtracker/alive')
async process() {
return {
status: 0,
expire: 3600,
time: Math.floor(new Date().valueOf() / 1000),
ecEnable: 1,
};
}
}
+15
View File
@@ -0,0 +1,15 @@
import { kxml } from "../../decorators/render-kxml.js";
import { ILaochanService } from "../../types.js";
import { singleton } from "tsyringe";
@singleton()
export default class implements ILaochanService {
@kxml('services/get')
async process() {
return {
keepaliveUrl: 'ping://127.0.0.1/?pa=127.0.0.1&ia=127.0.0.1&ga=127.0.0.1ma=127.0.0.1&t1=2&t2=15&rt=2',
ntpUrl: 'ntp://pool.ntp.org',
selfUrl: 'http://localhost:10573',
};
}
}
+82 -28
View File
@@ -1,34 +1,88 @@
/**
* Some predefined delay values (in milliseconds).
*/
export enum Delays {
Short = 500,
Medium = 2000,
Long = 5000,
import "reflect-metadata";
import Koa, { DefaultState } from 'koa';
import { InjectionToken, Provider, container } from "tsyringe";
import { ILaochanContext, ILaochanService } from './types.js';
import { DefaultService } from "./functions/default.js";
import { Logger } from "@cordisjs/logger";
import { crypto } from "./middlewares/crypto.js";
import { binaryBody } from "./middlewares/binarybody.js";
import { parseKxml } from "./middlewares/parseKxml.js";
function tryResolve<T>(token: InjectionToken<T>): T | undefined {
if (!container.isRegistered(token))
return undefined;
return container.resolve(token);
}
/**
* Returns a Promise<string> that resolves after a given time.
*
* @param {string} name - A name.
* @param {number=} [delay=Delays.Medium] - A number of milliseconds to delay resolution of the Promise.
* @returns {Promise<string>}
*/
function delayedHello(
name: string,
delay: number = Delays.Medium,
): Promise<string> {
return new Promise((resolve: (value?: string) => void) =>
setTimeout(() => resolve(`Hello, ${name}`), delay),
);
function register<T>(token: InjectionToken<T>, provider: Provider<T> | { new(...args: any[]): T }): T {
if (container.isRegistered(token))
return container.resolve(token);
// @ts-ignore
return container.register(token, provider)
.resolve(token);
}
// Please see the comment in the .eslintrc.json file about the suppressed rule!
// Below is an example of how to use ESLint errors suppression. You can read more
// at https://eslint.org/docs/latest/user-guide/configuring/rules#disabling-rules
async function main() {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function greeter(name: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
// The name parameter should be of type string. Any is used only to trigger the rule.
return await delayedHello(name, Delays.Long);
const logger = new Logger('main');
const app = new Koa<DefaultState, ILaochanContext>()
.use(binaryBody)
.use(crypto)
.use(parseKxml)
.use(async (ctx, next) => {
if (typeof ctx.query.f !== 'string') {
throw new Error('invaild service');
}
if (typeof ctx.query.model !== 'string') {
throw new Error('invaild model');
}
ctx.model = ctx.query.model;
ctx.service = ctx.query.f;
ctx.logger = ctx.service ? register(`logger:${ctx.service}`, {
useValue: new Logger(ctx.service),
}) : logger;
let service: ILaochanService | undefined = ctx.service ?
tryResolve(ctx.service) : container.resolve(DefaultService);
if (service === undefined) {
try {
const module = await import(`./functions/${ctx.service.replace('.', '/')}.js`);
service = register(ctx.service, {
useClass: module.default,
});
} catch { }
}
if (service === undefined) {
logger.warn(`unimplemented service ${ctx.service}`);
service = container.resolve(DefaultService);
}
logger.info('%s [%s]: request = %s', ctx.service, ctx.pcbid, JSON.stringify(ctx.request.body));
try {
ctx.body = await service.process(ctx);
ctx.status = 200;
} catch (e) {
logger.error('unexcepted error: %s', JSON.stringify(e, Object.getOwnPropertyNames(e), 2));
ctx.status = e.status ?? 500;
ctx.body = `${e.stack ?? 'Internal Error'}`;
}
logger.info('%s [server]: status = %d', ctx.service, ctx.status);
return await next();
});
app.listen(10573);
}
main();
+35
View File
@@ -0,0 +1,35 @@
import { Next } from "koa";
import { Context } from "../types.js";
export async function binaryBody(ctx: Context, next: Next) {
if (typeof ctx.headers["content-length"] !== 'string') {
return next();
}
const length = parseInt(ctx.headers["content-length"]);
if (length === 0) {
return next();
}
ctx.request.body = await new Promise((resolve, reject) => {
const buffer = Buffer.allocUnsafe(length);
let len = 0
ctx.req.on('data', (chunk: Buffer) => {
chunk.copy(buffer, len);
len += chunk.length;
if (len == length) {
resolve(buffer);
}
});
ctx.req.on('close', () => {
if (len < length) {
reject(new Error('socket hanged up unexceptly.'));
}
})
});
return next();
}
+41
View File
@@ -0,0 +1,41 @@
import { Next } from "koa";
import { Context } from "../types.js";
import { rc4Transfrom } from "../utils/rc4.js";
export async function crypto(ctx: Context, next: Next) {
if (typeof ctx.req.headers['x-eamuse-info'] !== 'string') {
return next();
}
if (!(ctx.request.body instanceof Buffer)) {
return next();
}
{
const eamuseInfo: string[] = ctx.req.headers['x-eamuse-info'].split('-');
const version = eamuseInfo[0];
const time = eamuseInfo[1];
const salt = eamuseInfo[2];
if (version !== '1') {
throw new Error(`unsupported crypto version ${version}`);
}
const decrypted = rc4Transfrom(time + salt, ctx.request.body);
ctx.request.body = decrypted;
}
await next();
if (!(ctx.body instanceof Buffer)) {
return;
}
const time = Math.floor(new Date().valueOf() / 1000).toString(16).padStart(8, '0');
const salt = Math.floor(Math.random() * 0xFFFF).toString(16).padStart(4, '0');
const encrypted = rc4Transfrom(time + salt, ctx.body);
ctx.body = encrypted;
ctx.set('X-Eamuse-Info', `1-${time}-${salt}`);
}
View File
+92
View File
@@ -0,0 +1,92 @@
import { Next } from "koa";
import { Context } from "../types.js";
import { to_xml } from "@kamyu/kbinxml";
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({
ignoreAttributes: false,
parseAttributeValue: true,
attributeNamePrefix: '$',
removeNSPrefix: true,
allowBooleanAttributes: true,
});
function parseValue(node: any) {
const isArray = '$__count' in node;
if (['s8', 'u8', 's16', 'u16', 's32', 'u32', 's64', 'u64', 'float', 'double'].includes(node.$__type)) {
if (isArray) {
return node['#text'].split(' ').map(v => new Number(v));
}
return new Number(node['#text']);
}
if(['b', 'bool'].includes(node.$__type)) {
return !!parseInt(node['#text']);
}
if (['bin', 'binary'].includes(node.$__type)) {
return Buffer.from(node['#text'], 'hex')
}
if(['ip4', 'str', 'string'].includes(node.$__type)) {
return node['#text'];
}
if(node.$__type == 'time') {
return new Date(parseInt(node['#text']) * 1000);
}
return {
type: node.$__type,
value: node['#text'],
}
}
function kxmlToObject(node: any) {
if (node instanceof Array) {
return node.map(v => kxmlToObject(v));
}
if ('$__type' in node) {
return parseValue(node);
}
const obj: object = {};
for (const key in node) {
if (key == '?xml') continue;
const value = node[key];
if (key.startsWith('$')) {
obj[key.substring(1)] = value;
continue;
}
obj[key] = kxmlToObject(node[key]);
}
return obj;
}
export async function parseKxml(ctx: Context, next: Next) {
if (!(ctx.request.body instanceof Buffer)) {
return next();
}
if ('x-dev' in ctx.request.headers) {
ctx.request.body = JSON.parse(ctx.request.body.toString('utf-8'));
} else {
const xmlResult = to_xml(ctx.request.body);
ctx.request.body = kxmlToObject(parser.parse(xmlResult.data));
}
const body = ctx.request.body as any;
if ('call' in body) {
ctx.pcbid = body.call.srcid;
}
return next();
}
+19
View File
@@ -0,0 +1,19 @@
import { Logger } from "@cordisjs/logger";
import { DefaultState, ParameterizedContext } from "koa";
export interface ILaochanContext {
model?: string;
service?: string;
pcbid?: string;
logger: Logger;
request: {
body: Buffer | object | undefined;
}
}
export type Context = ParameterizedContext<DefaultState, ILaochanContext, any>;
export interface ILaochanService {
process(ctx: Context): Promise<any>;
}
+13
View File
@@ -0,0 +1,13 @@
import { createHash } from "crypto";
import rc4 from 'arc4';
const EAMUSE_RC4_KEY = '69D74627D985EE2187161570D08D93B12455035B6DF0D8205DF5';
export function rc4Transfrom(keyHex: string, data: Buffer) {
const key = createHash('md5')
.update(keyHex + EAMUSE_RC4_KEY, 'hex')
.digest();
return rc4('arc4', key)
.encodeBuffer(data)
}
+47
View File
@@ -0,0 +1,47 @@
response
facility(method="get", status=status)
location
id(__type="str")= location.id
type(__type="u8")= location.type
country(__type="str")= location.country
region(__type="str")= location.region
name(__type="str")= location.name
countryname(__type="str")= location.countryName
countryjname(__type="str")= location.countryJName
regionname(__type="str")= location.regionName
regionjname(__type="str")= location.regionJName
customercode(__type="str")= location.customerCode
companycode(__type="str")= location.companyCode
latitude(__type="s32")= location.latitude
longitude(__type="s32")= location.longitude
accuracy(__type="u8")= location.accuracy
line
id(__type="str")= line.id
class(__type="u8")= line.class
upclass(__type="u8")= line.upClass
rtt(__type="u16")= line.rtt
portfw
globalip(__type="ip4")= portfw.globalIp
globalport(__type="u16")= portfw.globalPort
privateport(__type="u16")= portfw.privatePort
public
flag(__type="u8")= public.flag
name(__type="str")= public.name
latitude(__type="str")= public.latitude
longitude(__type="str")= public.longitude
share
url
epass(__type="str")= share.url
arcadefan(__type="str")= share.url
konaminetdx(__type="str")= share.url
konamiid(__type="str")= share.url
eagate(__type="str")= share.url
eapass
valid(__type="u16")= share.eapass
eacoin
notchamount(__type="s32")= share.eacoin.notchAmount
notchcount(__type="s32")= share.eacoin.notchCount
supplylimit(__type="s32")= share.eacoin.supplyLimit
calendar
year(__type="s16")= calendar.year
holiday(__type="s16" __count=calendar.holiday.length)= calendar.holiday.join(' ')
+6
View File
@@ -0,0 +1,6 @@
response
message(
expire=expire,
method="get",
status=status,
)
+8
View File
@@ -0,0 +1,8 @@
response
pcbtracker(
ecenable=ecEnable,
expire=expire,
method="alive",
status=status,
time=time,
)
+21
View File
@@ -0,0 +1,21 @@
response
services(expire=43200 method="get" mode="operation" product_domain=1 status=0)
item(name="keepalive" url=keepaliveUrl)
item(name="ntp" url=ntpUrl)
item(name="services" url=selfUrl)
item(name="dlstatus" url=selfUrl)
item(name="cardmng" url=selfUrl)
item(name="eacoin" url=selfUrl)
item(name="userdata" url=selfUrl)
item(name="userid" url=selfUrl)
item(name="pcbtracker" url=selfUrl)
item(name="pcbevent" url=selfUrl)
item(name="message" url=selfUrl)
item(name="facility" url=selfUrl)
item(name="apsmanager" url=selfUrl)
item(name="sidmgr" url=selfUrl)
item(name="package" url=selfUrl)
item(name="uploader" url=selfUrl)
item(name="local" url=selfUrl)
item(name="local2" url=selfUrl)
item(name="lobby" url=selfUrl)
+3 -1
View File
@@ -17,7 +17,9 @@
"noUnusedParameters": true,
"noImplicitAny": false,
"noImplicitThis": false,
"strictNullChecks": false
"strictNullChecks": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*", "__tests__/**/*"]
}