Version 1.3.0

- Implemented authentication and billing routes for Jpn region.
- Refactored and changed the project structure from CommonJS to ES Modules
This commit is contained in:
Junior 2025-04-29 16:20:09 -03:00
parent 9584e58143
commit c3d9e7afb5
76 changed files with 3847 additions and 1109 deletions

92
.env
View file

@ -2,34 +2,65 @@
# API CONFIGURATION #
##################################
# Set the port for receiving connections
PUBLIC_IP=
PORT=3000
AUTH_PORT=8070
BILLING_PORT=8080
# Set the host for receiving connections from the users for access launcher functions.
# Use 0.0.0.0 or leave empty for bind API on all IPs.
API_LISTEN_HOST=
# Set the port for receiving connections from the users for access launcher functions.
API_LISTEN_PORT=80
# Set the host for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions.
# Use 0.0.0.0 for bind API on all IPs (not recommended!).
API_LOCAL_LISTEN_HOST=127.0.0.1
# Allow determination of client IP address based on "X-Forwarded-For" header.
# This must be enabled if a reverse proxy is used. It is also necessary to specify the reverse
# proxy IP address in parameter API_TRUSTPROXY_HOSTS, otherwise data spoofing is possible.
API_TRUSTPROXY_ENABLE=false
# List of IP addresses or subnets that should be trusted as a reverse proxy.
# Multiple entries can be listed separated by commas.
# If left empty, headers will be accepted from any IP address (not recommended!).
API_TRUSTPROXY_HOSTS=
# Set the initial balance value of In-game Shop account on user registration.
API_SHOP_INITIAL_BALANCE=0
# Set the port for receiving connections for the Auth/Billing API (USA).
API_USA_PORT=8070
# Set the port for receiving connections for the Auth/Billing API (JPN).
API_JPN_PORT=8080
# Set the port for receiving connections from the proxy server (JPN).
API_PROXY_PORT=8090
# Determines whether the helmet middleware is enabled or disabled. If enabled https need to be used for the api.
# If set to true, the helmet middleware is included in the middleware stack, which adds various security-related HTTP headers to the application's responses to help prevent common web vulnerabilities.
# If set to false, the helmet middleware is not included in the middleware stack, and the application's responses will not have these extra headers.
ENABLE_HELMET=false
API_ENABLE_HELMET=false
# Set the server timezone
TZ=America/New_York
TZ=UTC
##################################
# LOGGING CONFIGURATION #
##################################
LOG_LEVEL=info
# Set log level (available levels: debug, info, warn, error).
LOG_LEVEL=debug
# Enable log IP addresses.
LOG_IP_ADDRESSES=false
LOG_AUTH_CONSOLE=true
LOG_BILLING_CONSOLE=true
LOG_ACCOUNT_CONSOLE=false
LOG_MAILER_CONSOLE=false
LOG_ACCOUNT_CONSOLE=true
LOG_MAILER_CONSOLE=true
##################################
# API DATABASE CONFIGURATION #
##################################
###########################################
# API DATABASE CONFIGURATION (SQL Server) #
###########################################
# Set a host to connect to the SQL server database.
DB_SERVER=127.0.0.1
@ -41,26 +72,30 @@ DB_DATABASE=RustyHearts_Account
DB_USER=sa
# Set the password to connect to database
DB_PASSWORD=
DB_PASSWORD=@RustyHearts
# Set to encrypt the connection to the database
DB_ENCRYPT=false
##################################
# GATEWAY API CONFIGURATION #
##################################
#########################
# GATEWAY CONFIGURATION #
#########################
# Set the host for receiving connections to the gateserver
GATESERVER_IP=YOUR_SERVER_IP
# Set the host for receiving connections to the GameGatewayServer
GATESERVER_IP=192.168.100.3
# Set the port for receiving connections to the gateserver
# Set the port for receiving connections to the GameGatewayServer
GATESERVER_PORT=50001
# Set the server/world id used in the database
SERVER_ID=10101
##################################
# EMAIL CONFIGURATION #
# SMTP CONFIGURATION #
##################################
# using gmail smtp server
# To generate app passwords, first you have to enable 2-Step Verification on our Google account.
# To generate app passwords, first you have to enable 2-Step Verification on your Google account.
# Go to your Google account security settings (https://myaccount.google.com/security) and enable 2-Step Verification
# Now, you can select the App passwords option to set up a new app password. https://myaccount.google.com/u/2/apppasswords
@ -73,11 +108,14 @@ SMTP_PORT=465
# The encryption protocol to use (e.g. ssl, tls)
SMTP_ENCRYPTION=ssl
# your email
SMTP_USERNAME=your.email@gmail.com
# The username of the SMTP server
SMTP_USERNAME=noreply@example.com
# app password
# The password/app password of the SMTP server
SMTP_PASSWORD=
# The name to use as the sender in emails
SMTP_FROMNAME=Rusty Hearts
# Outgoing mail sender email address.
SMTP_EMAIL_FROM_ADDRESS=noreply@example.com
# Outgoing mail sender name.
SMTP_FROM_NAME=Rusty Hearts

46
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Create Release and Upload Assets
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Get version from package.json
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "::set-output name=version::$VERSION"
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: "v${{ steps.get_version.outputs.version }}"
release_name: "RustyHearts-API v${{ steps.get_version.outputs.version }}"
draft: true
prerelease: false
- name: Upload Release Assets
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dist
asset_name: RustyHearts-API-v${{ steps.get_version.outputs.version }}.zip
asset_content_type: application/zip

1
.gitignore vendored
View file

@ -128,3 +128,4 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
package-lock.json

103
README.md
View file

@ -3,25 +3,17 @@
RustyHearts-API is a Node.js-based REST API that enables authentication, billing, and launcher functionalities for Rusty Hearts.
The API consists of three independent servers (Auth API, Billing API and Launcher API) running on different ports.
## Getting Started
Either use `git clone https://github.com/JuniorDark/RustyHearts-API` on the command line to clone the repository or use Code --> Download zip button to get the files.
### Preview
![image](api.png)
### API region support
The api currently only support the **usa** (PWE) region.
The API consists of independent servers (Auth/Billing API and Launcher API) running on different ports.
### API game region support
* **usa** (PWE) - Full api support
* **chn** (Xunlei) - Only launcher support
* **jpn** (SEGA) - Full api support
## Server Descriptions
### Servers
- The Auth API is responsible for in-game authentication, while the Billing API manages the shop's zen balance and purchases. It is essential to bind the Auth/Billing API only to a local IP address and prevent external access to these APIs.
- The Launcher API is a web server intended to handle the client connection to the gateserver and for the [Rusty Hearts Launcher](https://github.com/JuniorDark/RustyHearts-Launcher), which handles registration, login, client updates, and processing static elements (public directory). This API must be accessible from the outside and proxied by Nginx or bound to an external IP.
- **Launcher API**: The Launcher API is as a web server intended to handle the client connection to the gateserver and for the [Rusty Hearts Launcher](https://github.com/JuniorDark/RustyHearts-Launcher), which handles account registration, login, client updates, and processing static elements (public directory). This API must be accessible from the outside and proxied by Nginx or bound to an external IP.
- **Auth/Billing API (USA)/(JPN)**: This API is responsible for in-game authentication and handle the shop balance and purchases. It is recommended to bind this API only to a local IP address and prevent external access to these APIs.
- **Proxy (JPN)**: This server is used as a proxy to receive the request with malformed headers send from the game server, and forward it fixed to the Auth/Billing API.
## Table of Contents
* [Preview](#preview)
@ -33,6 +25,9 @@ The api currently only support the **usa** (PWE) region.
* [Available endpoints](#available-endpoints)
* [License](#license)
### Preview
![image](api.png)
## Public folder description
### Launcher self-update
@ -71,22 +66,29 @@ To deploy RustyHearts-API, follow these steps:
3. Open a terminal window, navigate to the RustyHearts-API directory, and execute the `npm install` command. Alternatively, you can run the **install.bat** file.
4. Import the [database file](share/RustyHearts_Account.sql) to your Microsoft SQL Server.
5. Configure the parameters in the [**.env**](.env) file.
6. Start RustyHearts-API servers by executing the `node src/app` command or running the **rh-api.bat** file.
7. The server region must be set to **usa** on [service_control.xml](share/service_control.xml)
6. Start RustyHearts-API servers by running the file **start-JPN** or **start-USA** file.
7. Set the server region to **usa** or **jpn** on [service_control.xml](share/service_control.xml)
## .env file setup:
### API CONFIGURATION
- **PORT**: The port number for receiving connections (default 3000).
- **AUTH_PORT**: The port number for the Auth API.
- **BILLING_PORT**: The port number for the Billing API.
- **ENABLE_HELMET**: Determines whether the helmet middleware is enabled or disabled. If enabled, https need to be used for the api.
- **API_LISTEN_HOST**: The host for receiving connections from the users for access public/launcher functions. Use `0.0.0.0` or leave empty to bind API on all IPs.
- **API_LISTEN_PORT**: The port number for receiving connections from the users for access public/launcher functions (default 80).
- **API_LOCAL_LISTEN_HOST**: The host for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions. Use `127.0.0.1` (recommended) or `0.0.0.0` to bind API on all IPs (not recommended!).
- **API_USA_PORT**: The port number for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions for the usa region.
- **API_JPN_PORT**: The port number for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions for the jpn region.
- **API_PROXY_PORT**: The port number for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions for the jpn region. This port is used to receive requests with malformed headers and forward them to the Auth/Billing API.
- **API_TRUSTPROXY_ENABLE**: Allow determination of client IP address based on `X-Forwarded-For` header. Must be enabled if a reverse proxy is used.
- **API_TRUSTPROXY_HOSTS**: List of IP addresses or subnets that should be trusted as a reverse proxy. Multiple entries can be listed separated by commas. If left empty, headers will be accepted from any IP address (not recommended!).
- **API_SHOP_INITIAL_BALANCE**: The initial balance value of the in-game shop on user registration.
- **API_ENABLE_HELMET**: Determines whether the helmet middleware is enabled or disabled. If enabled, HTTPS needs to be used for the API.
- **TZ**: The timezone for the server.
### LOGGING CONFIGURATION
- **LOG_LEVEL**: The level of logging to use (e.g. debug, info, warn, error).
- **LOG_LEVEL**: The level of logging to use (e.g., debug, info, warn, error).
- **LOG_IP_ADDRESSES**: Enable logging of IP addresses.
- **LOG_AUTH_CONSOLE**: Whether to log Auth API messages to the console.
- **LOG_BILLING_CONSOLE**: Whether to log Billing API messages to the console.
- **LOG_ACCOUNT_CONSOLE**: Whether to log Account API messages to the console.
@ -100,40 +102,57 @@ To deploy RustyHearts-API, follow these steps:
- **DB_PASSWORD**: The password for the database user.
- **DB_ENCRYPT**: Whether to encrypt the connection to the database.
### GATEWAY API CONFIGURATION
### GATEWAY CONFIGURATION
- **GATESERVER_IP**: The IP address of the gate server.
- **GATESERVER_PORT**: The port number of the gate server.
- **SERVER_ID**: The server/world ID used in the database.
### EMAIL CONFIGURATION
- **SMTP_HOST**: The hostname or IP address of the SMTP server.
- **SMTP_PORT**: The port number of the SMTP server.
- **SMTP_ENCRYPTION**: The encryption protocol to use (e.g. ssl, tls).
- **SMTP_ENCRYPTION**: The encryption protocol to use (e.g., ssl, tls).
- **SMTP_USERNAME**: The username for the SMTP server.
- **SMTP_PASSWORD**: The password for the SMTP server.
- **SMTP_FROMNAME**: The name to use as the sender in emails.
- **SMTP_EMAIL_FROM_ADDRESS**: The outgoing mail sender email address.
- **SMTP_FROM_NAME**: The outgoing mail sender name.
## Available endpoints
The api provides the following endpoints:
The API provides the following endpoints:
Endpoint | Method | Arguments | Description
--- | --- | --- | ---
/serverApi/auth | POST | XML with account, password, game and IP | Authenticates a user game login based on their account information and sends an XML response with their user ID, user type, and success status. If authentication fails, it sends an XML response with a failure status.
/serverApi/billing | POST | XML with currency-request or item-purchase-request and associated arguments | Handles billing requests. For currency requests, it retrieves the user's Zen balance from the database and sends an XML response with the balance. For item purchase requests, it deducts the cost of the item from the user's Zen balance and logs the transaction in the database. If the transaction is successful, it sends an XML response with the success status. If the transaction fails, it sends an XML response with a failure status and an error message.
/serverApi/gateway | GET | | Returns an XML response containing the IP address and port number of the gateway server.
/serverApi/gateway/info | GET | | Returns an response containing the gateway endpoint. Used by the **chn** region.
/serverApi/gateway/status | GET | | Checks the status of the gateway server by attempting to establish a connection to the server. Returns a JSON object with the status of the server (online or offline) and an HTTP status code indicating the success or failure of the connection attempt.
/accountApi/register | POST | windyCode, email, password | Create a new account with the provided windyCode, email, and password. The password is first combined with the windyCode to create an MD5 hash, which is then salted and hashed again using bcrypt before being stored in the database. An email confirmation is sent to the provided email address, and a success or error message is returned.
/accountApi/login | POST | account, password | Authenticates a user account in the launcher by username or email address and password. Return a token if the authentication is successful (token is currently unsued).
/accountApi/codeVerification | POST | email, verification_code_type, verification_code | Verify a user's email by checking the verification code
/accountApi/sendPasswordResetEmail | POST | email | Sends an email with a password reset verification code to the specified email address
/accountApi/changePassword | POST | email, password, verification_code | Change the password of a user's account, given the email and password verification code
/accountApi/sendVerificationEmail | POST | email | Sends a verification email to the specified email address.
/launcherApi/launcherUpdater/getLauncherVersion | GET | | Returns the version of the launcher by reading the launcher_info.ini file.
/launcherApi/launcherUpdater/updateLauncherVersion | POST | version | Download the specified launcher versionr from the launcher_update folder.
/serverApi/onlineCount | GET | | Returns the number of online players. Returns a JSON object with the count.
### Launcher API
Endpoint | Method | Arguments | Content Type | Description
--- | --- | --- | --- | ---
/Register | - | -| - | A basic web page for account registration and password change. |
/launcher/GetGatewayAction | GET | - | XML | Returns the gateway server's IP and port in XML format used by the client to connect to the server.
/launcher/SignupAction | POST | `userName`, `email`, `password`, `verificationCode`| Form URL Encoded | Registers a new user account.
/launcher/LoginAction | POST | `account`, `password` | Form URL Encoded | Authenticates a user by username/email and returns a token if successful. |
/launcher/ResetPasswordAction | POST | `email`, `password`, `verificationCode` | Form URL Encoded | Resets a user's password using a verification code. |
/launcher/SendPasswordResetEmailAction | POST | `email` | Form URL Encoded | Sends a email with a verification code for password reset to the specified address. |
/launcher/SendVerificationEmailAction | POST | `email` | Form URL Encoded | Sends a email with a verification code for account creation reset to the specified address. |
/launcher/VerifyCodeAction | POST | `email`, `verificationCodeType`, `verificationCode` | Form URL Encoded | Validates a verification code. `verificationCodeType`: `Account`, `Password` |
/launcher/LauncherAction/getLauncherVersion | GET | - | JSON | Returns the version of the launcher specified in the launcher_info.ini file. |
/launcher/LauncherAction/updateLauncherVersion | POST | `version` | Form URL Encoded | Download the specified launcher version from the launcher_update folder. |
launcher/GetOnlineCountAction | GET | - | JSON | Returns the number of current online players. |
### Auth/Billing API (USA)
Endpoint | Method | Arguments | Content Type | Description
--- | --- | --- | --- | ---
/Auth | POST | `<login-request><account>account</account><password>account + password in md5</password><game>16</game><ip>ip</ip></login-request>`| XML | Authenticates a user in the client by username and password. The password must be hashed using MD5. The game ID is always 16 for the USA region. |
/Billing | POST | `<currency-request><userid>account</userid><game>1000</game><server>serverId</server></currency-request>` | XML | Returns the shop balance of the user account. The game ID is always 1000 for the USA region. |
/Billing | POST | `<item-purchase-request><userid>account</userid><charid>character GUID</charid><game>1000</game><server>serverId</server><amount>itemPrice</amount><itemid>itemid</itemid><count>itemCount</count><uniqueid>Transaction GUID</uniqueid></item-purchase-request>` | XML | Purchases an item from the shop. The game ID is always 1000 for the USA region. The server ID is the same as the one used in the Auth API. The item ID is the same as the one used in the shop. The transaction GUID is a unique identifier for the purchase. |
### Auth/Billing API (JPN)
Endpoint | Method | Arguments | Content Type | Description
--- | --- | --- | --- | ---
/Auth/cgi-bin/auth_rest_oem.cgi | POST | `service_id`, `product_name`, `original_id`, `original_password`| Form URL Encoded | authenticates a user in the client by username and password. service_id is always `FFFFFFFFFFFFFFFFFF` for the JPN region. The product_name is always empty. |
/Billing/S1/ApiPointTotalGetS.php | POST | `product_name`, `original_id`, `original_password`, `auto_charge_exec` | Form URL Encoded | Returns the shop balance of the user account. The auto_charge_exec is always 0. |
/Billing/S1/ApiPointMoveS.php | POST | `product_name`, `original_id`, `original_password`, `auto_charge_exec`, `move_point`, ` move_kind`, `item_code` | Form URL Encoded | Purchases an item from the shop. `move_kind` is always 06. `move_point` is the cost of the item `-200`. The `item_code` format is `rsty0000000000001`, where the first 10 digits are the item shopID and the last 3 digits are always `001`. |
## License
This project is licensed under the terms found in [`LICENSE-0BSD`](LICENSE).

BIN
api.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

View file

@ -0,0 +1,90 @@
"use strict";
// CHANGES MADE ARE APPLIED ONLY AFTER THE PROCESS IS RESTARTED.
// All set points of all users are reset when API processes are restarted.
// ATTENTION!
// The client's IP address is used as an identifier. Therefore, if the API is behind a reverse
// proxy (nginx, CloudFlare), make sure that parameter "API_TRUSTPROXY_ENABLE" is
// set to "TRUE" so that the client's IP address is determined correctly.
// Configuration parameters can be found here:
// https://github.com/animir/node-rate-limiter-flexible/wiki/Options
export const api = {
// Launcher
launcher: {
// POST /launcher/SignupAction
signupAction: {
points: 20,
duration: 3600, // 1 hour
blockDuration: 3600,
},
// POST /launcher/LoginAction
loginAction: {
points: 60,
duration: 300, // 5 minutes
blockDuration: 1800, // 30 minutes block
},
// POST /launcher/VerifyCodeAction
verifyCodeAction: {
points: 30,
duration: 300,
blockDuration: 1800,
},
// POST /launcher/ResetPasswordAction
resetPasswordAction: {
points: 30,
duration: 300,
blockDuration: 3600,
},
// POST /launcher/SendPasswordResetEmailAction
sendPasswordResetEmailAction: {
points: 30,
duration: 300, // 15 minutes
blockDuration: 3600, // 1 hour
},
// POST /launcher/SendVerificationEmailAction
sendVerificationEmailAction: {
points: 30,
duration: 300,
blockDuration: 3600,
},
// POST /launcher/GetGatewayAction
getGatewayAction: {
points: 30,
duration: 300,
blockDuration: 600,
},
// POST /launcher/GetOnlineCountAction
getOnlineCountAction: {
points: 30,
duration: 180,
blockDuration: 300,
},
},
launcherAction: {
// POST /launcherAction/getLauncherVersion
getLauncherVersion: {
points: 30,
duration: 1800, // 30 minutes
blockDuration: 3600,
},
// POST /launcherAction/updateLauncherVersion
updateLauncherVersion: {
points: 30,
duration: 1800, // 30 minutes
blockDuration: 3600,
}
}
};

40
ecosystem.config.cjs Normal file
View file

@ -0,0 +1,40 @@
module.exports = {
apps: [
{
name: 'rh-api-all',
script: 'src/app.js',
args: 'mainApp usaApp jpnApp proxyServer',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
}
},
{
name: 'rh-api-usa',
script: 'src/app.js',
args: 'mainApp usaApp',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
}
},
{
name: 'rh-api-jpn',
script: 'src/app.js',
args: 'mainApp jpnApp proxyServer',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
}
}
]
};

118
env.example Normal file
View file

@ -0,0 +1,118 @@
##################################
# API CONFIGURATION #
##################################
# Set the host for receiving connections from the users for access launcher functions.
# Use 0.0.0.0 or leave empty for bind API on all IPs.
API_LISTEN_HOST=
# Set the port for receiving connections from the users for access launcher functions.
API_LISTEN_PORT=80
# Set the host for receiving connections from the GameGatewayServer/ManagerServer servers (Rusty Hearts Servers) for the auth/billing functions.
# Use 0.0.0.0 for bind API on all IPs (not recomended!).
API_LOCAL_LISTEN_HOST=127.0.0.1
# Allow determination of client IP address based on "X-Forwarded-For" header.
# This must be enabled if a reverse proxy is used. It is also necessary to specify the reverse
# proxy IP address in parameter API_TRUSTPROXY_HOSTS, otherwise data spoofing is possible.
API_TRUSTPROXY_ENABLE=false
# List of IP addresses or subnets that should be trusted as a reverse proxy.
# Multiple entries can be listed separated by commas.
# If left empty, headers will be accepted from any IP address (not recommended!).
API_TRUSTPROXY_HOSTS=
# Set the initial balance value of In-game Shop account on user registration.
API_SHOP_INITIAL_BALANCE=0
# Set the port for receiving connections for the Auth/Billing API (USA).
API_USA_PORT=8070
# Set the port for receiving connections for the Auth/Billing API (JPN).
API_JPN_PORT=8080
# Set the port for receiving connections from the proxy server (JPN).
API_PROXY_PORT=8090
# Determines whether the helmet middleware is enabled or disabled. If enabled https need to be used for the api.
# If set to true, the helmet middleware is included in the middleware stack, which adds various security-related HTTP headers to the application's responses to help prevent common web vulnerabilities.
# If set to false, the helmet middleware is not included in the middleware stack, and the application's responses will not have these extra headers.
API_ENABLE_HELMET=false
# Set the server timezone
TZ=UTC
##################################
# LOGGING CONFIGURATION #
##################################
# Set log level (available levels: debug, info, warn, error).
LOG_LEVEL=debug
# Enable log IP addresses.
LOG_IP_ADDRESSES=false
LOG_AUTH_CONSOLE=true
LOG_BILLING_CONSOLE=true
LOG_ACCOUNT_CONSOLE=true
LOG_MAILER_CONSOLE=true
###########################################
# API DATABASE CONFIGURATION (SQL Server) #
###########################################
# Set a host to connect to the SQL server database.
DB_SERVER=127.0.0.1
# Set the name of database
DB_DATABASE=RustyHearts_Account
# Set the user to connect to database
DB_USER=sa
# Set the password to connect to database
DB_PASSWORD=@RustyHearts
# Set to encrypt the connection to the database
DB_ENCRYPT=false
#########################
# GATEWAY CONFIGURATION #
#########################
# Set the host for receiving connections to the GameGatewayServer
GATESERVER_IP=192.168.100.3
# Set the port for receiving connections to the GameGatewayServer
GATESERVER_PORT=50001
# Set the server/world id used in the database
SERVER_ID=10101
##################################
# SMTP CONFIGURATION #
##################################
# using gmail smtp server
# To generate app passwords, first you have to enable 2-Step Verification on our Google account.
# Go to your Google account security settings (https://myaccount.google.com/security) and enable 2-Step Verification
# Now, you can select the App passwords option to set up a new app password. https://myaccount.google.com/u/2/apppasswords
# The hostname or IP address of the SMTP server
SMTP_HOST=smtp.gmail.com
# The port number of the SMTP server
SMTP_PORT=465
# The encryption protocol to use (e.g. ssl, tls)
SMTP_ENCRYPTION=ssl
# The username of the SMTP server
SMTP_USERNAME=noreply@example.com
# The password/app password of the SMTP server
SMTP_PASSWORD=
# Outgoing mail sender email address.
SMTP_EMAIL_FROM_ADDRESS=noreply@example.com
# Outgoing mail sender name.
SMTP_FROM_NAME=Rusty Hearts

View file

@ -1,6 +1,7 @@
{
"name": "RustyHearts-API",
"version": "1.2.0",
"name": "rustyhearts-api",
"version": "1.3.0",
"type": "module",
"description": "Rusty Hearts REST API implementation on node.js",
"main": "src/app.js",
"scripts": {
@ -22,24 +23,26 @@
},
"dependencies": {
"bcryptjs": "^3.0.2",
"compression": "^1.7.4",
"compression": "^1.8.0",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.2.1",
"express-winston": "^4.2.0",
"handlebars": "^4.7.7",
"helmet": "^8.0.0",
"joi": "^17.9.2",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"joi": "^17.13.3",
"logger": "^0.0.1",
"moment-timezone": "^0.5.43",
"mssql": "^11.0.0",
"moment-timezone": "^0.5.48",
"mssql": "^11.0.1",
"node-cache": "^5.1.2",
"nodemailer": "^6.9.1",
"nodemailer": "^6.10.1",
"pm2": "^6.0.5",
"winston": "^3.8.2",
"xml2js": "^0.6.0"
"rate-limiter-flexible": "^7.1.0",
"winston": "^3.17.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1"
},
"engines": {
"node": ">=18.15.0"

View file

@ -1,2 +1,2 @@
[LAUNCHER]
version=1.2.0
version=1.4.0

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>News Panel</title>
<link rel="stylesheet" href="/launcher/news/style.css">
<link rel="stylesheet" href="/launcher/news/css/style.css">
</head>
<body oncontextmenu="return false;">
<div class="slider-container">
@ -28,20 +28,20 @@
<div class="tab-link" data-tab="info">Info</div>
</div>
<div class="tab-content tab events active">
<a href="https://your-website.com/news/Halloween.html">Halloween</a> - <span class="tab-date">20/10/2023</span><br>
<a href="https://your-website.com/news/Winter.html">Winter</a> - <span class="tab-date">10/12/2023</span><br>
<a href="https://your-website.com/news/Happy_New_Year.html">Happy New Year</a> - <span class="tab-date">01/01/2024</span><br>
<a href="https://your-website.com/news/Halloween.html">Halloween</a> - <span class="tab-date">20/10/2025</span><br>
<a href="https://your-website.com/news/Winter.html">Winter</a> - <span class="tab-date">10/12/2025</span><br>
<a href="https://your-website.com/news/Happy_New_Year.html">Happy New Year</a> - <span class="tab-date">01/01/2025</span><br>
</div>
<div class="tab-content tab notices">
<a href="#">Notice 1</a> - <span class="tab-date">01/01/2023</span><br>
<a href="#">Notice 2</a> - <span class="tab-date">02/01/2023</span><br>
<a href="#">Notice 3</a> - <span class="tab-date">03/01/2023</span><br>
<a href="#">Notice 1</a> - <span class="tab-date">01/01/2025</span><br>
<a href="#">Notice 2</a> - <span class="tab-date">02/01/2025</span><br>
<a href="#">Notice 3</a> - <span class="tab-date">03/01/2025</span><br>
</div>
<div class="tab-content tab info">
<a href="#">Info 1</a> - <span class="tab-date">01/01/2023</span><br>
<a href="#">Info 2</a> - <span class="tab-date">02/01/2023</span><br>
<a href="#">Info 3</a> - <span class="tab-date">03/01/2023</span><br>
<a href="#">Info 1</a> - <span class="tab-date">01/01/2025</span><br>
<a href="#">Info 2</a> - <span class="tab-date">02/01/2025</span><br>
<a href="#">Info 3</a> - <span class="tab-date">03/01/2025</span><br>
</div>
<script src="/launcher/news/script.js"></script>
<script src="/launcher/news/js/script.js"></script>
</body>
</html>

134
public/site/Signup.html Normal file
View file

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rusty Hearts - Account Management</title>
<link rel="stylesheet" href="/site/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
<div class="container">
<div class="left-panel">
<div class="logo-container">
<img src="/site/images/rh_logo.png" alt="Rusty Hearts Logo" class="logo">
</div>
<div class="game-description">
<h3>Join the Battle</h3>
<p>Experience a action hack'n'slash multiplayer online game with fast-paced and highly-stylized brawling combat combined with a solo or team-based dungeon exploration experience.</p>
</div>
</div>
<div class="right-panel">
<div class="form-tabs">
<button class="tab-button active" data-tab="register">Create Account</button>
<button class="tab-button" data-tab="reset">Reset Password</button>
</div>
<!-- Register Form -->
<div id="register-form" class="form-content active">
<form id="signupForm">
<div class="form-group">
<label for="userName">Username</label>
<div class="input-with-icon">
<i class="fas fa-user"></i>
<input type="text" id="userName" name="userName" placeholder="6-16 alphanumeric characters" required>
</div>
<div class="error-message" id="userNameError"></div>
</div>
<div class="form-group">
<label for="email">Email</label>
<div class="input-with-icon">
<i class="fas fa-envelope"></i>
<input type="email" id="email" name="email" placeholder="your@email.com" required>
<button type="button" id="sendVerificationBtn" class="verification-btn">Send Code</button>
</div>
<div class="error-message" id="emailError"></div>
</div>
<div class="form-group">
<label for="verificationCode">Verification Code</label>
<div class="input-with-icon">
<i class="fas fa-shield-alt"></i>
<input type="text" id="verificationCode" name="verificationCode" placeholder="Enter verification code" required>
</div>
<div class="error-message" id="verificationCodeError"></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-with-icon">
<i class="fas fa-lock"></i>
<input type="password" id="password" name="password" placeholder="8-16 characters" required>
</div>
<div class="error-message" id="passwordError"></div>
</div>
<div id="registerResponse" class="response-message"></div>
</form>
</div>
<!-- Reset Password Form -->
<div id="reset-form" class="form-content">
<form id="resetPasswordForm">
<div class="form-group">
<label for="resetEmail">Email</label>
<div class="input-with-icon">
<i class="fas fa-envelope"></i>
<input type="email" id="resetEmail" name="email" placeholder="your@email.com" required>
<button type="button" id="sendResetVerificationBtn" class="verification-btn">Send Code</button>
</div>
<div class="error-message" id="resetEmailError"></div>
</div>
<div class="form-group">
<label for="resetVerificationCode">Verification Code</label>
<div class="input-with-icon">
<i class="fas fa-shield-alt"></i>
<input type="text" id="resetVerificationCode" name="verificationCode" placeholder="Enter verification code" required>
</div>
<div class="error-message" id="resetVerificationCodeError"></div>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<div class="input-with-icon">
<i class="fas fa-lock"></i>
<input type="password" id="newPassword" name="password" placeholder="8-16 characters" required>
</div>
<div class="error-message" id="newPasswordError"></div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<div class="input-with-icon">
<i class="fas fa-lock"></i>
<input type="password" id="confirmPassword" placeholder="Repeat your password" required>
</div>
<div class="error-message" id="confirmPasswordError"></div>
</div>
<div id="resetResponse" class="response-message"></div>
</form>
</div>
<!-- Dynamic Footer -->
<div class="form-footer">
<div class="footer-content register-footer active">
<button type="submit" form="signupForm" class="submit-btn">Create Account</button>
<div class="footer-links">
<div class="reset-link">
Forgot password? <a href="#" class="switch-tab" data-tab="reset">Reset Password</a>
</div>
</div>
</div>
<div class="footer-content reset-footer">
<button type="submit" form="resetPasswordForm" class="submit-btn">Reset Password</button>
<div class="footer-links">
<div class="register-link">
Need an account? <a href="#" class="switch-tab" data-tab="register">Create Account</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/site/js/script.js"></script>
</body>
</html>

442
public/site/css/style.css Normal file
View file

@ -0,0 +1,442 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Open Sans', sans-serif;
background-color: #1a1a1a;
background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)), url('/site/images/rh1920x1200.jpg');
background-size: cover;
background-repeat: no-repeat;
color: #e0e0e0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
display: flex;
max-width: 1200px;
width: 90%;
max-height: 90vh;
min-height: 600px;
background-color: #2a2a2a;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
/* Left Panel Styles */
.left-panel {
background-color: white;
flex: 1;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
padding: 0px;
display: flex;
flex-direction: column;
position: relative;
align-items: center;
justify-content: space-between;
}
.logo-container {
margin-bottom: 10px;
}
.logo {
max-width: 300px;
}
.game-description {
text-align: center;
}
.game-description h2, h3 {
font-family: 'MedievalSharp', cursive;
color: #c62828;
margin-bottom: 10px;
font-size: 24px;
}
.game-description p {
font-size: 14px;
line-height: 1.5;
}
/* Right Panel Styles */
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
}
.form-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px 0;
}
.form-container h2 {
font-family: 'MedievalSharp', cursive;
color: #c62828;
margin-bottom: 30px;
text-align: center;
font-size: 28px;
}
.form-group {
margin-bottom: 20px;
flex-shrink: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #e0e0e0;
}
#signupForm {
display: flex;
flex-direction: column;
min-height: 100%;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon i {
position: absolute;
left: 15px;
color: #777;
}
.input-with-icon input {
width: 100%;
padding: 12px 120px 12px 40px;
border: 2px solid #444;
border-radius: 5px;
background-color: #333;
color: #e0e0e0;
font-size: 14px;
transition: all 0.3s;
}
.input-with-icon input:focus {
border-color: #c62828;
outline: none;
box-shadow: 0 0 5px rgba(198, 40, 40, 0.5);
}
.error-message {
color: #ff5252;
font-size: 12px;
margin-top: 5px;
height: 16px;
}
.form-wrapper {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.form-header {
flex-shrink: 0;
padding: 10px 0;
}
.form-scrollable {
flex: 1;
overflow-y: auto;
padding: 10px 0;
}
.form-content {
flex: 1;
overflow-y: auto;
padding: 10px 0;
margin-bottom: 10px;
}
/* Better scrollbar styling */
.form-content::-webkit-scrollbar {
width: 6px;
}
.form-content::-webkit-scrollbar-track {
background: #333;
}
.form-content::-webkit-scrollbar-thumb {
background: #c62828;
border-radius: 3px;
}
/* Form Footer - Always Visible */
.form-footer {
flex-shrink: 0;
padding: 4px 0;
margin-top: auto;
border-top: 1px solid #444;
background-color: #2a2a2a;
position: sticky;
bottom: 0;
}
.submit-btn {
width: 100%;
padding: 12px;
background-color: #c62828;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s;
}
.submit-btn:hover {
background-color: #b71c1c;
}
.login-link,
.reset-link,
.register-link {
text-align: center;
font-size: 14px;
}
.login-link a {
color: #c62828;
text-decoration: none;
font-weight: 600;
}
.login-link a:hover {
text-decoration: underline;
}
.response-message {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
text-align: center;
display: none;
}
.response-message.success {
background-color: rgba(76, 175, 80, 0.2);
color: #4CAF50;
display: block;
}
.response-message.error {
background-color: rgba(244, 67, 54, 0.2);
color: #f44336;
display: block;
}
.legal-links {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
font-size: 12px;
}
.legal-links a {
color: #777;
text-decoration: none;
}
.legal-links a:hover {
color: #c62828;
}
/* Footer Styles */
.footer-content {
display: none;
width: 100%;
}
.footer-content.active {
display: block;
}
.footer-links {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
.switch-tab {
color: #c62828;
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.switch-tab:hover {
text-decoration: underline;
}
/* Responsive Styles */
@media (max-width: 480px) {
.footer-links {
flex-direction: column;
}
}
@media (max-height: 700px) {
.container {
max-height: 95vh;
min-height: 400px;
}
.form-group {
margin-bottom: 12px;
}
.input-with-icon input {
padding: 10px 100px 10px 35px;
font-size: 13px;
}
.form-group label {
font-size: 14px;
}
}
@media (max-width: 768px) {
.container {
flex-direction: column;
max-height: none;
height: auto;
}
.left-panel {
padding: 20px;
}
.game-screenshot {
display: none;
}
}
/* Verification Button Styles */
.verification-btn {
position: absolute;
right: 10px;
background-color: #333;
color: #c62828;
border: 1px solid #c62828;
border-radius: 3px;
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.verification-btn:hover {
background-color: #c62828;
color: white;
}
.verification-btn:disabled {
background-color: #333;
color: #777;
border-color: #444;
cursor: not-allowed;
}
/* Tab Styles */
.form-tabs {
display: flex;
border-bottom: 1px solid #444;
margin-bottom: 20px;
}
.tab-button {
flex: 1;
padding: 12px 0;
background: none;
border: none;
color: #777;
font-family: 'MedievalSharp', cursive;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.tab-button.active {
color: #c62828;
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
background-color: #c62828;
}
/* Form Content */
.form-content {
display: none;
flex: 1;
overflow-y: auto;
padding: 10px 0;
margin-bottom: 10px;
}
.form-content.active {
display: block;
}
/* Submit Buttons */
#resetSubmit {
display: none;
}
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.form-content.active {
animation: fadeIn 0.3s ease-out;
}
.password-match {
border-color: #4CAF50 !important;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.5) !important;
}
.password-mismatch {
border-color: #f44336 !important;
box-shadow: 0 0 5px rgba(244, 67, 54, 0.5) !important;
}

BIN
public/site/images/001.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/site/images/002.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/site/images/006.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
public/site/images/012.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/site/images/020.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
public/site/images/021.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
public/site/images/022.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
public/site/images/023.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
public/site/images/rh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

461
public/site/js/script.js Normal file
View file

@ -0,0 +1,461 @@
document.addEventListener("DOMContentLoaded", function () {
function setRandomBackground() {
const images = ["001.jpg", "002.jpg", "006.jpg", "012.jpg", "020.jpg", "021.jpg", "022.jpg", "023.jpg"];
const randomImage = images[Math.floor(Math.random() * images.length)];
const panel = document.querySelector(".left-panel");
if (panel) {
panel.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url('/site/images/${randomImage}')`;
}
}
setRandomBackground();
// Tab switching functionality
const tabs = document.querySelectorAll(".tab-button");
const formContents = document.querySelectorAll(".form-content");
const footers = document.querySelectorAll(".footer-content");
// Switch tab function
function switchTab(tabName) {
// Update tabs
tabs.forEach((tab) => {
tab.classList.toggle("active", tab.getAttribute("data-tab") === tabName);
});
// Update forms
formContents.forEach((form) => {
form.classList.toggle("active", form.id === `${tabName}-form`);
});
// Update footers
footers.forEach((footer) => {
footer.classList.toggle(
"active",
footer.classList.contains(`${tabName}-footer`)
);
});
}
// Tab click event
tabs.forEach((tab) => {
tab.addEventListener("click", function () {
const tabName = this.getAttribute("data-tab");
switchTab(tabName);
});
});
// Switch tab link click event
document.querySelectorAll(".switch-tab").forEach((link) => {
link.addEventListener("click", function (e) {
e.preventDefault();
const tabName = this.getAttribute("data-tab");
switchTab(tabName);
});
});
// Register form functionality
const signupForm = document.getElementById("signupForm");
const registerResponse = document.getElementById("registerResponse");
const sendVerificationBtn = document.getElementById("sendVerificationBtn");
let cooldownInterval;
// Password reset form functionality
const resetPasswordForm = document.getElementById("resetPasswordForm");
const resetResponse = document.getElementById("resetResponse");
const sendResetVerificationBtn = document.getElementById(
"sendResetVerificationBtn"
);
let resetCooldownInterval;
// Shared functions
function startCooldown(button, intervalVar, seconds = 60) {
let secondsLeft = seconds;
updateButtonText(button, secondsLeft);
intervalVar = setInterval(() => {
secondsLeft--;
updateButtonText(button, secondsLeft);
if (secondsLeft <= 0) {
clearInterval(intervalVar);
resetVerificationButton(button);
}
}, 1000);
return intervalVar;
}
function updateButtonText(button, seconds) {
button.textContent = `Resend (${seconds}s)`;
}
function resetVerificationButton(button) {
button.disabled = false;
button.textContent = "Send Code";
}
function showError(elementId, message) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = message;
}
}
function clearErrorMessages(formPrefix = "") {
const errorElements = document.querySelectorAll(
`.error-message${formPrefix ? `[id^=${formPrefix}]` : ""}`
);
errorElements.forEach((element) => {
element.textContent = "";
});
}
function showResponseMessage(element, message, type) {
element.textContent = message;
element.className = "response-message " + type;
}
// Verification code sending functionality for REGISTER form
sendVerificationBtn.addEventListener("click", async function () {
const email = document.getElementById("email").value.trim();
// Clear previous error
document.getElementById("emailError").textContent = "";
// Basic email validation
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showError("emailError", "Please enter a valid email address");
return;
}
// Disable button and start cooldown
sendVerificationBtn.disabled = true;
cooldownInterval = startCooldown(sendVerificationBtn, cooldownInterval);
try {
const response = await fetch("/launcher/SendVerificationEmailAction", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ email }),
});
const data = await response.json();
if (!response.ok || !data.success) {
if (data.message === "AccountExists") {
showError("emailError", "Email is already registered");
} else {
showError(
"emailError",
"Failed to send verification code: " + data.message
);
}
resetVerificationButton(sendVerificationBtn);
} else {
showResponseMessage(
registerResponse,
"Verification code sent to your email",
"success"
);
}
} catch (error) {
console.error("Error sending verification:", error);
showError("emailError", "Failed to send verification code");
resetVerificationButton(sendVerificationBtn);
}
});
// Verification code sending functionality for PASSWORD RESET form
sendResetVerificationBtn.addEventListener("click", async function () {
const email = document.getElementById("resetEmail").value.trim();
clearErrorMessages("reset");
document.getElementById("resetEmailError").textContent = "";
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showError("resetEmailError", "Please enter a valid email address");
return;
}
sendResetVerificationBtn.disabled = true;
resetCooldownInterval = startCooldown(
sendResetVerificationBtn,
resetCooldownInterval
);
try {
const response = await fetch("/launcher/SendPasswordResetEmailAction", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ email }),
});
const data = await response.json();
if (!response.ok || !data.success) {
if (data.message === "AccountNotFound") {
showError("resetEmailError", "No account found with this email");
} else {
showError(
"resetEmailError",
"Failed to send verification code: " + data.message
);
}
resetVerificationButton(sendResetVerificationBtn);
} else {
showResponseMessage(
resetResponse,
"Password reset code sent to your email",
"success"
);
}
} catch (error) {
console.error("Error sending verification:", error);
showError("resetEmailError", "Failed to send verification code");
resetVerificationButton(sendResetVerificationBtn);
}
});
// Form submission handlers
signupForm.addEventListener("submit", async function (e) {
e.preventDefault();
clearErrorMessages();
showResponseMessage(registerResponse, "", "");
const formData = {
userName: document.getElementById("userName").value.trim(),
email: document.getElementById("email").value.trim(),
password: document.getElementById("password").value.trim(),
verificationCode: document
.getElementById("verificationCode")
.value.trim(),
};
// Validation
try {
const response = await fetch("/launcher/SignupAction", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(formData),
});
const data = await response.json();
if (response.ok) {
if (data.success) {
showResponseMessage(
registerResponse,
"Account created successfully!",
"success"
);
} else {
handleServerErrors(data.message, formData, "");
}
} else {
showResponseMessage(
registerResponse,
data.message || "An error occurred. Please try again.",
"error"
);
}
} catch (error) {
console.error("Error:", error);
showResponseMessage(
registerResponse,
"An error occurred. Please try again.",
"error"
);
}
});
resetPasswordForm.addEventListener("submit", async function (e) {
e.preventDefault();
clearErrorMessages("reset");
showResponseMessage(resetResponse, "", "");
const formData = {
email: document.getElementById("resetEmail").value.trim(),
password: document.getElementById("newPassword").value.trim(),
verificationCode: document
.getElementById("resetVerificationCode")
.value.trim(),
};
// Validate the form
if (!validateResetForm(formData)) {
return;
}
try {
const response = await fetch("/launcher/ResetPasswordAction", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(formData),
});
const data = await response.json();
if (response.ok) {
if (data.success) {
showResponseMessage(
resetResponse,
"Password changed successfully!",
"success"
);
// Clear password fields on success
document.getElementById("newPassword").value = "";
document.getElementById("confirmPassword").value = "";
} else {
handleServerErrors(data.message, formData, "reset");
}
} else {
showResponseMessage(
resetResponse,
data.message || "An error occurred. Please try again.",
"error"
);
}
} catch (error) {
console.error("Error:", error);
showResponseMessage(
resetResponse,
"An error occurred. Please try again.",
"error"
);
}
});
function validateResetForm(formData) {
let isValid = true;
// Email validation
if (!formData.email) {
showError("resetEmailError", "Email is required");
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
showError("resetEmailError", "Please enter a valid email address");
isValid = false;
}
// Verification code validation
if (!formData.verificationCode) {
showError("resetVerificationCodeError", "Verification code is required");
isValid = false;
} else if (!/^[0-9]+$/.test(formData.verificationCode)) {
showError(
"resetVerificationCodeError",
"Verification code must contain only numbers"
);
isValid = false;
}
// Password validation
const newPassword = document.getElementById("newPassword").value.trim();
const confirmPassword = document
.getElementById("confirmPassword")
.value.trim();
if (!newPassword) {
showError("newPasswordError", "Password is required");
isValid = false;
} else if (newPassword.length < 8 || newPassword.length > 16) {
showError("newPasswordError", "Password must be 8-16 characters");
isValid = false;
}
if (!confirmPassword) {
showError("confirmPasswordError", "Please confirm your password");
isValid = false;
} else if (newPassword !== confirmPassword) {
showError("confirmPasswordError", "Passwords do not match");
showError("newPasswordError", "Passwords do not match");
isValid = false;
}
return isValid;
}
// real-time password matching feedback
const newPasswordInput = document.getElementById("newPassword");
const confirmPasswordInput = document.getElementById("confirmPassword");
function checkPasswordMatch() {
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (newPassword && confirmPassword) {
if (newPassword === confirmPassword) {
newPasswordInput.classList.add("password-match");
newPasswordInput.classList.remove("password-mismatch");
confirmPasswordInput.classList.add("password-match");
confirmPasswordInput.classList.remove("password-mismatch");
showError("confirmPasswordError", "");
showError("newPasswordError", "");
} else {
newPasswordInput.classList.add("password-mismatch");
newPasswordInput.classList.remove("password-match");
confirmPasswordInput.classList.add("password-mismatch");
confirmPasswordInput.classList.remove("password-match");
}
} else {
newPasswordInput.classList.remove("password-match", "password-mismatch");
confirmPasswordInput.classList.remove(
"password-match",
"password-mismatch"
);
}
}
newPasswordInput.addEventListener("input", checkPasswordMatch);
confirmPasswordInput.addEventListener("input", checkPasswordMatch);
function handleServerErrors(errorCode, formData, prefix = "") {
switch (errorCode) {
case "UsernameExists":
showError(`${prefix}userNameError`, "Username is already in use");
break;
case "EmailExists":
showError(`${prefix}EmailError`, "Email is already registered");
break;
case "AccountNotFound":
showError(`${prefix}EmailError`, "No account found with this email");
break;
case "InvalidVerificationCode":
showError(
`${prefix}VerificationCodeError`,
"Invalid verification code"
);
break;
case "ExpiredVerificationCode":
showError(
`${prefix}VerificationCodeError`,
"Verification code has expired, please request a new one"
);
break;
case "SamePassword":
showResponseMessage(
resetResponse,
"New password cannot be the same as the old password",
"error"
);
break;
default:
const responseElement = prefix ? resetResponse : registerResponse;
showResponseMessage(
responseElement,
"An error occurred: " + errorCode,
"error"
);
}
}
});

View file

@ -1,4 +0,0 @@
@echo off
title Rusty Hearts API
node src/app
pause

View file

@ -1,4 +0,0 @@
@echo off
title API
cmd /k "npx pm2 start src/app.js --name rh-api && npx pm2 logs rh-api"
pause

View file

@ -0,0 +1,145 @@
-- ----------------------------
-- procedure structure for CreateAccount
-- ----------------------------
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[CreateAccount]') AND type IN ('P', 'PC', 'RF', 'X'))
DROP PROCEDURE[dbo].[CreateAccount]
GO
CREATE PROCEDURE [dbo].[CreateAccount]
@WindyCode varchar(50),
@AccountPwd varchar(255),
@Email varchar(255),
@RegisterIP varchar(16),
@ServerId int,
@ShopBalance Bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Result varchar(20)
DECLARE @EmailExists int;
DECLARE @UsernameExists int;
DECLARE @WindyCodeExists int;
BEGIN TRY
BEGIN TRANSACTION
SELECT @EmailExists = COUNT(*) FROM AccountTable
WHERE Email = @Email;
SELECT @UsernameExists = COUNT(*) FROM AccountTable
WHERE WindyCode = @WindyCode;
SELECT @WindyCodeExists = COUNT(*) FROM RustyHearts_Auth.dbo.AuthTable
WHERE WindyCode = @WindyCode;
-- Check if account exists
IF @EmailExists > 0
SET @Result = 'EmailExists';
ELSE IF @UsernameExists > 0
SET @Result = 'UsernameExists';
ELSE IF @WindyCodeExists > 0
SET @Result = 'UsernameExists';
ELSE
SET @Result = 'NewUser';
-- Create new account
IF @Result = 'NewUser'
BEGIN
INSERT INTO AccountTable (WindyCode, AccountPwd, Email, RegisterIP, CreatedAt, LastLogin, IsLocked, LoginAttempts, LastLoginIP)
VALUES (@WindyCode, @AccountPwd, @Email, @RegisterIP, GETDATE(), GETDATE(), 0, 0, @RegisterIP);
INSERT INTO RustyHearts_Auth.dbo.AuthTable (WindyCode, world_id, AuthID, Tcount, online, CTime, BTime, LTime, IP, LCount, ServerIP, ServerType, HostID, DBCIndex, InquiryCount, event_inquiry, CashMileage, channelling, pc_room_point, externcash, mac_addr, mac_addr02, mac_addr03, second_pass)
VALUES (@WindyCode, 0, NEWID(), 0, '0', GETDATE(), GETDATE(), GETDATE(), @RegisterIP, 0, 0, 0, 0, 0, 5, 1, 0, 1, 0, 0, '00-00-00-00-00-00', '00-00-00-00-00-00', '00-00-00-00-00-00', '');
INSERT INTO CashTable (WindyCode, WorldId, Zen)
VALUES (@WindyCode, @ServerId, @ShopBalance);
SET @Result = 'AccountCreated';
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
SET @Result = 'TransactionFailed';
END CATCH
SELECT @Result as Result;
END
GO
-- ----------------------------
-- procedure structure for SetAccountVerificationCode
-- ----------------------------
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[SetAccountVerificationCode]') AND type IN ('P', 'PC', 'RF', 'X'))
DROP PROCEDURE[dbo].[SetAccountVerificationCode]
GO
CREATE PROCEDURE [dbo].[SetAccountVerificationCode]
@VerificationCode varchar(10),
@Email varchar(255),
@ExpirationTime DATETIME
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Result varchar(20)
DECLARE @AccountExists int;
DECLARE @VerificationCodeCount int;
BEGIN TRY
BEGIN TRANSACTION
SELECT @AccountExists = COUNT(*) FROM AccountTable
WHERE Email = @Email;
-- Check if account exists
IF @AccountExists > 0
BEGIN
SET @Result = 'AccountExists';
COMMIT TRANSACTION;
SELECT @Result as Result;
RETURN;
END
IF @Result = 'AccountDontExists'
-- Retrieve count of existing verification codes for the user
SELECT @VerificationCodeCount = COUNT(*) FROM VerificationCode
WHERE Email = @Email;
-- Check if count of existing verification codes is less than 5
IF @VerificationCodeCount < 5
BEGIN
-- Insert new verification code
INSERT INTO VerificationCode (VerificationCode, Email, ExpirationTime, Type)
VALUES (@VerificationCode, @Email, @ExpirationTime, 'Account');
SET @Result = 'Success';
END
ELSE
BEGIN
-- Delete all existing verification codes for the user
DELETE FROM VerificationCode WHERE Email = @Email;
-- Insert new verification code
INSERT INTO VerificationCode (VerificationCode, Email, ExpirationTime, Type)
VALUES (@VerificationCode, @Email, @ExpirationTime, 'Account');
SET @Result = 'Success';
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
SET @Result = 'TransactionFailed';
END CATCH
SELECT @Result as Result;
END
GO

View file

@ -1,21 +1,3 @@
/*
Navicat Premium Data Transfer
Source Server : RH VM
Source Server Type : SQL Server
Source Server Version : 16001050
Source Host : 192.168.100.125:1433
Source Catalog : RustyHearts_Account
Source Schema : dbo
Target Server Type : SQL Server
Target Server Version : 16001050
File Encoding : 65001
Date: 12/05/2023 14:59:51
*/
-- ----------------------------
-- Table structure for AccountTable
-- ----------------------------
@ -495,33 +477,40 @@ IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[Cr
GO
CREATE PROCEDURE [dbo].[CreateAccount]
@WindyCode varchar(50),
@AccountPwd varchar(255),
@Email varchar(255),
@RegisterIP varchar(16)
@WindyCode varchar(50),
@AccountPwd varchar(255),
@Email varchar(255),
@RegisterIP varchar(16),
@ServerId int,
@ShopBalance Bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Result varchar(20)
DECLARE @AccountExists int;
DECLARE @EmailExists int;
DECLARE @UsernameExists int;
DECLARE @WindyCodeExists int;
BEGIN TRY
BEGIN TRANSACTION
SELECT @AccountExists = COUNT(*) FROM AccountTable
WHERE WindyCode = @WindyCode OR Email = @Email;
SELECT @EmailExists = COUNT(*) FROM AccountTable
WHERE Email = @Email;
SELECT @UsernameExists = COUNT(*) FROM AccountTable
WHERE WindyCode = @WindyCode;
SELECT @WindyCodeExists = COUNT(*) FROM RustyHearts_Auth.dbo.AuthTable
WHERE WindyCode = @WindyCode;
-- Check if account exists
IF @AccountExists > 0
SET @Result = 'AccountExists';
IF @EmailExists > 0
SET @Result = 'EmailExists';
ELSE IF @UsernameExists > 0
SET @Result = 'UsernameExists';
ELSE IF @WindyCodeExists > 0
SET @Result = 'WindyCodeExists';
SET @Result = 'UsernameExists';
ELSE
SET @Result = 'NewUser';
@ -535,7 +524,7 @@ BEGIN
VALUES (@WindyCode, 0, NEWID(), 0, '0', GETDATE(), GETDATE(), GETDATE(), @RegisterIP, 0, 0, 0, 0, 0, 5, 1, 0, 1, 0, 0, '00-00-00-00-00-00', '00-00-00-00-00-00', '00-00-00-00-00-00', '');
INSERT INTO CashTable (WindyCode, WorldId, Zen)
VALUES (@WindyCode, 10101, 0);
VALUES (@WindyCode, @ServerId, @ShopBalance);
SET @Result = 'AccountCreated';
@ -664,10 +653,13 @@ BEGIN
-- Check if account exists
IF @AccountExists > 0
IF @AccountExists > 0
BEGIN
SET @Result = 'AccountExists';
ELSE
SET @Result = 'AccountDontExists';
COMMIT TRANSACTION;
SELECT @Result as Result;
RETURN;
END
IF @Result = 'AccountDontExists'
-- Retrieve count of existing verification codes for the user

View file

@ -1,9 +1,8 @@
<?xml version="1.0" ?>
<service>
<active_area country="usa" />
<area country="usa" auth_url="http://localhost:8070/serverApi/auth" billing_url="http://localhost:8080/serverApi/billing" billing_idc="10101" xtrap_use="0" server_mode="WAG" betazone="0" />
<area country="chn" skip_auth="1" free_cash="1" skip_abuse_nick ="1" enc_xml_use ="1" billing_idc="10101" xtrap_use="0" server_mode="WAG" betazone="0" />
<active_area country="jpn" />
<area country="usa" auth_url="http://127.0.0.1:8070/Auth" billing_url="http://127.0.0.1:8070/Billing" billing_idc="10101" xtrap_use="0" server_mode="WAG" betazone="0" />
<area country="jpn" auth_url="http://127.0.0.1:8090/Auth" billing_url="http://127.0.0.1:8090/Billing/" skip_auth="0" skip_billing="0" free_cash="0" skip_abuse_nick="0" second_pass="0" betazone="0" server_mode="WAG" channel_limit_count="1" world_user_limit_count="100" billing_idc="10101" />
<area country="chn" skip_auth="1" free_cash="1" skip_abuse_nick ="1" billing_idc="10101" xtrap_use="0" server_mode="WAG" betazone="0" />
</service>

View file

@ -1,163 +1,48 @@
// Load environment variables
const env = require('./utils/env');
import config from './config.js';
const { logger } = config;
import { logSystemInfo } from './utils/systemInfo.js';
import memoryLogger from './utils/memoryLogger.js';
const { setupMemoryLogging } = memoryLogger;
// Import modules
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const expressWinston = require('express-winston');
const moment = require('moment-timezone');
const { logger } = require('./utils/logger');
const path = require('path');
// Import servers
import { startServer as startMainApp } from './servers/mainApp.js';
import { startServer as startUsaApp } from './servers/usaApp.js';
import { startServer as startJpnApp } from './servers/jpnApp.js';
import { startServer as startProxyServer } from './servers/proxyServer.js';
// Import routes
const authRouter = require('./routes/auth');
const billingRouter = require('./routes/billing');
const gatewayRouter = require('./routes/gateway');
const loginRouter = require('./routes/launcher/login');
const registerRouter = require('./routes/launcher/register');
const codeVerificationRouter = require('./routes/launcher/codeVerification');
const passwordResetEmailRouter = require('./routes/launcher/passwordResetEmail');
const passwordChangeRouter = require('./routes/launcher/changePassword');
const verificationEmailRouter = require('./routes/launcher/verificationEmail');
const launcherUpdaterRouter = require('./routes/launcher/launcherUpdater');
const onlineCountRouter = require('./routes/onlineCount');
// Parse command line arguments
const args = process.argv.slice(2);
const serversToStart = args.length > 0 ? args : ['mainApp'];
// Set up rate limiter
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // limit each IP to 60 requests per minute
message: 'Too many requests from this IP, please try again later'
});
// Start selected servers
const activeServers = [];
const app = express();
// Set up middleware
const middleware = [
cors(),
compression(),
express.json(),
express.urlencoded({ extended: false }),
];
if (process.env.ENABLE_HELMET === 'true') {
middleware.unshift(helmet());
if (serversToStart.includes('mainApp')) {
activeServers.push(startMainApp());
}
app.use(...middleware);
const authPort = process.env.AUTH_PORT || 8070;
const billingPort = process.env.BILLING_PORT || 8080;
// Set up routes
app.use('/serverApi/auth', limiter, authRouter).listen(authPort, '127.0.0.1');
app.use('/serverApi/billing', limiter , billingRouter).listen(billingPort, '127.0.0.1');
app.use('/serverApi/gateway', limiter , gatewayRouter);
app.use('/accountApi/register', limiter , registerRouter);
app.use('/accountApi/login', limiter , loginRouter);
app.use('/accountApi/codeVerification', limiter , codeVerificationRouter);
app.use('/accountApi/sendPasswordResetEmail', limiter , passwordResetEmailRouter);
app.use('/accountApi/changePassword', limiter , passwordChangeRouter);
app.use('/accountApi/sendVerificationEmail', limiter , verificationEmailRouter);
app.use('/launcherApi/launcherUpdater', launcherUpdaterRouter);
app.use('/serverApi/onlineCount', limiter , onlineCountRouter);
// Serve static files from public folder
app.use(express.static('../public'));
// Serve static files for the launcher
app.get('/launcher/news', (req, res) => {
res.sendFile(path.join(__dirname, '../public/launcher/news/news-panel.html'));
});
app.get('/launcher/agreement', (req, res) => {
res.sendFile(path.join(__dirname, '../public/launcher/news/agreement.html'));
});
app.get('/favicon.ico', (req, res) => {
res.sendFile(path.join(__dirname, '../public/launcher/news/favicon.ico'));
});
app.use('/launcher/news/images', express.static(path.join(__dirname, '../public/launcher/news/images')));
app.use('/launcher/news', express.static(path.join(__dirname, '../public/launcher/news')));
app.use('/launcher/patch', express.static(path.join(__dirname, '../public/launcher/patch')));
app.use('/launcher/client', express.static(path.join(__dirname, '../public/launcher/client')));
// Set up error handling middleware
app.use((err, req, res, next) => {
if (env.LOG_LEVEL && env.LOG_LEVEL === 'error') {
logger.error(err.stack);
} else {
logger.info(err.stack);
}
res.status(500).send('A error ocurred. Try again later.');
});
// Node.js version
const nodeVersion = process.version;
// timezone
const timezone = process.env.TZ || new Date().toLocaleString('en-US', { timeZoneName: 'short' });
const offsetInMinutes = moment.tz(timezone).utcOffset();
const offsetHours = Math.floor(Math.abs(offsetInMinutes) / 60);
const offsetMinutes = Math.abs(offsetInMinutes) % 60;
const offsetSign = offsetInMinutes >= 0 ? '+' : '-';
const offsetString = `${offsetSign}${offsetHours.toString().padStart(2, '0')}:${offsetMinutes.toString().padStart(2, '0')}`;
const memoryUsage = process.memoryUsage();
// Function to format bytes as human-readable string
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) {
return '0 B';
}
const i = Math.floor(Math.log2(bytes) / 10);
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
if (serversToStart.includes('usaApp')) {
activeServers.push(startUsaApp());
}
// Start server
const port = process.env.PORT || 3000;
const publicIP = process.env.PUBLIC_IP || '0.0.0.0';
console.log('--------------------------------------------------');
console.log(`Rusty Hearts API Version: 1.2`)
console.log(`Node.js Version: ${nodeVersion}`);
console.log(`Timezone: ${timezone} (${offsetString})`);
console.log('Memory Usage:');
console.log(` RSS: ${formatBytes(memoryUsage.rss)}`);
console.log(` Heap Total: ${formatBytes(memoryUsage.heapTotal)}`);
console.log(` Heap Used: ${formatBytes(memoryUsage.heapUsed)}`);
console.log(` External: ${formatBytes(memoryUsage.external)}`);
console.log(` Array Buffers: ${formatBytes(memoryUsage.arrayBuffers)}`);
console.log('--------------------------------------------------');
// Function to log memory usage
function logMemoryUsage() {
const now = new Date();
const formattedDateTime = moment(now).format('YYYY-MM-DD HH:mm:ss');
const memoryUsage = process.memoryUsage();
console.log(`Memory Usage at ${formattedDateTime}:`);
console.log(` RSS : ${formatBytes(memoryUsage.rss)}`);
console.log(` Heap Total : ${formatBytes(memoryUsage.heapTotal)}`);
console.log(` Heap Used : ${formatBytes(memoryUsage.heapUsed)}`);
console.log(` External : ${formatBytes(memoryUsage.external)}`);
console.log(` Array Buffers: ${formatBytes(memoryUsage.arrayBuffers)}`);
console.log('--------------------------------------------------');
if (serversToStart.includes('jpnApp')) {
activeServers.push(startJpnApp());
}
// Log memory usage every 30 minutes (1800000 milliseconds)
const memoryLogInterval = 1800000;
if (serversToStart.includes('proxyServer')) {
activeServers.push(startProxyServer());
}
setInterval(logMemoryUsage, memoryLogInterval);
// System Info
logSystemInfo();
app.listen(port, publicIP, () => {
logger.info(`API listening on ${publicIP}:${port}`);
logger.info(`Auth API listening on 127.0.0.1:${authPort}`);
logger.info(`Billing API listening on 127.0.0.1:${billingPort}`);
// Memory Logging
const stopMemoryLogging = setupMemoryLogging();
// Handle shutdown
process.on('SIGINT', () => {
logger.info('Shutting down servers...');
activeServers.forEach(server => server.close());
stopMemoryLogging();
process.exit();
});

100
src/config.js Normal file
View file

@ -0,0 +1,100 @@
import dotenv from 'dotenv';
dotenv.config();
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import cors from 'cors';
import compression from 'compression';
import express from 'express';
import helmet from 'helmet';
import { logger } from './utils/logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default {
// Port configurations
ports: {
main: process.env.API_LISTEN_PORT || 80,
usaApp: process.env.API_USA_PORT || 8070,
jpnApp: process.env.API_JPN_PORT || 8080,
proxy: process.env.API_PROXY_PORT || 8090,
gate: process.env.GATESERVER_PORT || 50001,
},
// IP configurations
ips: {
public: process.env.API_LISTEN_HOST || '0.0.0.0',
local: process.env.API_LOCAL_LISTEN_HOST || '127.0.0.1',
gate: process.env.GATESERVER_IP,
},
// configurations
config: {
serverId: Number(process.env.SERVER_ID) || 10101,
shopBalance: process.env.API_SHOP_INITIAL_BALANCE || 0,
timeZone: process.env.TZ,
},
// API configurations
apiConfig: {
trustProxyEnabled: process.env.API_TRUSTPROXY_ENABLE || 'false',
trustProxyHosts: process.env.API_TRUSTPROXY_HOSTS || [],
logIPAddresses: process.env.LOG_IP_ADDRESSES || 'false',
},
// Mailer configurations
mailer: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_ENCRYPTION === 'ssl' || process.env.SMTP_ENCRYPTION === 'tls',
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
}
},
// middleware
middleware: {
baseMiddleware: [
cors(),
compression(),
express.json(),
express.urlencoded({ extended: true }),
],
getMiddleware: function () {
const middleware = [...this.baseMiddleware];
if (process.env.API_ENABLE_HELMET === 'true') {
middleware.unshift(helmet());
}
return middleware;
}
},
// Static file paths
staticPaths: {
public: path.join(__dirname, '../public'),
launcherNews: path.join(__dirname, '../public/launcher/news'),
launcherNewsImages: path.join(__dirname, '../public/launcher/news/images'),
launcherPatch: path.join(__dirname, '../public/launcher/patch'),
launcherClient: path.join(__dirname, '../public/launcher/client'),
site: path.join(__dirname, '../public/site'),
},
// Logger
logger,
// Backend configuration for proxy
BACKENDS: {
AUTH: {
paths: ['/Auth/cgi-bin/auth_rest_oem.cgi']
},
BILLING: {
paths: ['/Billing/S1/ApiPointTotalGetS.php', '/Billing/S1/ApiPointMoveS.php']
}
}
};

View file

@ -0,0 +1,41 @@
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { api } from '../../config/rateLimits.default.js';
class RateLimitConfig {
constructor() {
this.limiters = new Map();
this.loadConfig();
}
loadConfig() {
// API Limiters for /launcher
for (const [endpoint, config] of Object.entries(api.launcher)) {
this.limiters.set(
`/launcher/${endpoint.charAt(0).toUpperCase()}${endpoint.slice(1)}`,
new RateLimiterMemory({
points: config.points,
duration: config.duration,
blockDuration: config.blockDuration
})
);
}
// API Limiters for /launcherAction
for (const [endpoint, config] of Object.entries(api.launcherAction)) {
this.limiters.set(
`/launcherAction/${endpoint}`,
new RateLimiterMemory({
points: config.points,
duration: config.duration,
blockDuration: config.blockDuration
})
);
}
}
getLimiter(path) {
return this.limiters.get(path);
}
}
export default new RateLimitConfig();

View file

@ -0,0 +1,7 @@
// This middleware sets the "Connection" header to "close" for all responses.
// It ensures that the connection is closed after the response is sent, which can help with resource management and performance.
// The 'next()' function is called to pass control to the next middleware or route handler in the stack.
export const closeConnection = (req, res, next) => {
res.set("Connection", "close");
next();
};

43
src/lib/rateLimiter.js Normal file
View file

@ -0,0 +1,43 @@
import rateLimitConfig from "../config/rateLimitConfig.js";
import { getClientIp } from "../utils/getClientIp.js";
async function rateLimiter(req, res, next) {
try {
const path = (req.baseUrl + req.path).replace(/\/+$/, "");
const limiterKey = Array.from(rateLimitConfig.limiters.keys()).find((key) =>
path.startsWith(key)
);
if (!limiterKey) {
console.warn(`[RateLimiter] No limiter found for path: "${path}"`);
return next();
}
const limiter = rateLimitConfig.getLimiter(limiterKey);
const clientIP = getClientIp(req);
const rateLimitRes = await limiter.consume(clientIP);
res.set({
"Retry-After": rateLimitRes.msBeforeNext / 1000,
"X-RateLimit-Limit": limiter.points,
"X-RateLimit-Remaining": rateLimitRes.remainingPoints,
"X-RateLimit-Reset": new Date(Date.now() + rateLimitRes.msBeforeNext),
});
next();
} catch (rateLimitRes) {
res.set({
"Retry-After": rateLimitRes.msBeforeNext / 1000,
});
return res.status(429).json({
error: "Too many requests",
message: `You've exceeded the rate limit. Please try again in ${Math.ceil(
rateLimitRes.msBeforeNext / 1000
)} seconds.`,
});
}
}
export default rateLimiter;

View file

@ -1,18 +1,24 @@
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const fs = require('fs');
const path = require('path');
const { mailerLogger } = require('../utils/logger');
import { createTransport } from 'nodemailer';
import handlebars from 'handlebars';
import { readFileSync } from 'fs';
import { logger, mailerLogger } from '../utils/logger.js';
import path from 'path';
import config from '../config.js';
const { mailer } = config;
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Load the email templates
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load and compile email templates
const emailTemplates = {
confirmation: fs.readFileSync(path.join(__dirname, 'templates', 'confirmationTemplate.hbs'), 'utf-8'),
verification: fs.readFileSync(path.join(__dirname, 'templates', 'verificationTemplate.hbs'), 'utf-8'),
passwordReset: fs.readFileSync(path.join(__dirname, 'templates', 'passwordResetTemplate.hbs'), 'utf-8'),
passwordChanged: fs.readFileSync(path.join(__dirname, 'templates', 'passwordChangedTemplate.hbs'), 'utf-8')
confirmation: readFileSync(path.join(__dirname, 'templates', 'confirmationTemplate.hbs'), 'utf-8'),
verification: readFileSync(path.join(__dirname, 'templates', 'verificationTemplate.hbs'), 'utf-8'),
passwordReset: readFileSync(path.join(__dirname, 'templates', 'passwordResetTemplate.hbs'), 'utf-8'),
passwordChanged: readFileSync(path.join(__dirname, 'templates', 'passwordChangedTemplate.hbs'), 'utf-8')
};
// Compile the email templates
const compiledTemplates = {
confirmation: handlebars.compile(emailTemplates.confirmation),
verification: handlebars.compile(emailTemplates.verification),
@ -20,23 +26,47 @@ const compiledTemplates = {
passwordChanged: handlebars.compile(emailTemplates.passwordChanged)
};
// SMTP transport configuration
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_ENCRYPTION === 'ssl' || process.env.SMTP_ENCRYPTION === 'tls',
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
}
});
// Check for required environment variables
function isMailerConfigured() {
const requiredEnvVars = [
'SMTP_HOST',
'SMTP_PORT',
'SMTP_ENCRYPTION',
'SMTP_USERNAME',
'SMTP_PASSWORD',
'SMTP_FROM_NAME'
];
const missing = requiredEnvVars.filter(key => !process.env[key]);
if (missing.length) {
logger.warn(`[Mailer] SMTP server is not configured. Missing environment variables: ${missing.join(', ')}`);
return false;
}
return true;
}
// Create transporter
function getTransporter() {
return createTransport({
host: mailer.host,
port: Number(mailer.port),
secure: mailer.secure,
auth: {
user: mailer.auth.user,
pass: mailer.auth.pass
},
});
}
// Email send functions
function sendConfirmationEmail(email, windyCode) {
const template = compiledTemplates.confirmation;
const emailContent = template({ windyCode });
if (!isMailerConfigured()) return;
const transporter = getTransporter();
const emailContent = compiledTemplates.confirmation({ windyCode });
const mailOptions = {
from: `"${process.env.SMTP_FROMNAME}" <${process.env.SMTP_USERNAME}>`,
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_EMAIL_FROM_ADDRESS}>`,
to: email,
subject: '[Rusty Hearts] Account Creation Confirmation',
html: emailContent
@ -52,11 +82,13 @@ function sendConfirmationEmail(email, windyCode) {
}
function sendVerificationEmail(email, verificationCode) {
const template = compiledTemplates.verification;
const emailContent = template({ verificationCode });
if (!isMailerConfigured()) return;
const transporter = getTransporter();
const emailContent = compiledTemplates.verification({ verificationCode });
const mailOptions = {
from: `"${process.env.SMTP_FROMNAME}" <${process.env.SMTP_USERNAME}>`,
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_EMAIL_FROM_ADDRESS}>`,
to: email,
subject: '[Rusty Hearts] Account Creation',
html: emailContent
@ -72,11 +104,13 @@ function sendVerificationEmail(email, verificationCode) {
}
function sendPasswordResetEmail(email, verificationCode) {
const template = compiledTemplates.passwordReset;
const emailContent = template({ verificationCode });
if (!isMailerConfigured()) return;
const transporter = getTransporter();
const emailContent = compiledTemplates.passwordReset({ verificationCode });
const mailOptions = {
from: `"${process.env.SMTP_FROMNAME}" <${process.env.SMTP_USERNAME}>`,
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_EMAIL_FROM_ADDRESS}>`,
to: email,
subject: '[Rusty Hearts] Password Reset Request',
html: emailContent
@ -92,11 +126,13 @@ function sendPasswordResetEmail(email, verificationCode) {
}
function sendPasswordChangedEmail(email, windyCode) {
const template = compiledTemplates.passwordChanged;
const emailContent = template({ windyCode });
if (!isMailerConfigured()) return;
const transporter = getTransporter();
const emailContent = compiledTemplates.passwordChanged({ windyCode });
const mailOptions = {
from: `"${process.env.SMTP_FROMNAME}" <${process.env.SMTP_USERNAME}>`,
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_EMAIL_FROM_ADDRESS}>`,
to: email,
subject: '[Rusty Hearts] Account Password Changed',
html: emailContent
@ -111,4 +147,9 @@ function sendPasswordChangedEmail(email, windyCode) {
});
}
module.exports = {sendConfirmationEmail, sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail};
export {
sendConfirmationEmail,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
};

View file

@ -1,86 +0,0 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const xml2js = require('xml2js');
const sql = require('mssql');
const Joi = require('joi');
const { authLogger } = require('../utils/logger');
const { connAccount } = require('../utils/dbConfig');
const router = express.Router();
const parser = new xml2js.Parser({ explicitArray: false });
// Joi schema for request body validation
const schema = Joi.object({
'login-request': Joi.object({
account: Joi.string().required(),
password: Joi.string().required(),
game: Joi.string().required(),
ip: Joi.string().required(),
}).required(),
});
// Route for handling login requests
router.post('/', express.text({ type: '*/xml' }), async (req, res) => {
try {
const xml = req.body;
const result = await parser.parseStringPromise(xml);
// Validate the request body against the schema
const { error, value } = schema.validate(result);
if (error) {
authLogger.info(`[Auth] Invalid login request: ${error.message}`);
return res.send('<status>failed</status>');
}
const { 'login-request': loginRequest } = value;
const { account, password, game, ip } = loginRequest;
authLogger.info(`[Auth] Account [${account}] is trying to login from [${ip}]`);
// Create a connection pool for the database
const pool = await connAccount;
// Get the account information from the database
const { recordset } = await pool
.request()
.input('Identifier', sql.VarChar(50), account)
.execute('GetAccount');
// Check if the account exists
const row = recordset[0];
if (!row || row.Result !== 'AccountExists') {
authLogger.info(`[Auth] Account [${account}] login failed from [${ip}]`);
return res.send('<status>failed</status>');
}
// Verify the password using bcrypt
const passwordMatch = await bcrypt.compare(password, row.AccountPwd);
// Authenticate the user and update the database
const { recordset: authRecordset } = await pool
.request()
.input('Identifier', sql.VarChar(50), account)
.input('password_verify_result', sql.Bit, passwordMatch ? 1 : 0)
.input('LastLoginIP', sql.VarChar(50), ip)
.execute('AuthenticateUser');
const authRow = authRecordset[0];
if (!authRow || authRow.Result !== 'LoginSuccess') {
authLogger.info(`[Auth] Account [${account}] login failed from [${ip}]`);
return res.send('<status>failed</status>');
}
// Send the authentication response
const response = `<userid>${authRow.AuthID}</userid><user-type>F</user-type><status>success</status>`;
res.set('Content-Type', 'text/xml; charset=utf-8');
res.send(response);
authLogger.info(`[Auth] Account [${account}] successfully logged in from [${ip}]`);
} catch (error) {
authLogger.error(`Error handling login request: ${error.message}`);
res.status(500).send('<status>failed</status>');
}
});
module.exports = router;

114
src/routes/authJpn.js Normal file
View file

@ -0,0 +1,114 @@
import { Router, urlencoded } from "express";
import joi from "joi";
import config from "../config.js";
const { apiConfig } = config;
import { authLogger } from "../utils/logger.js";
import { authenticateUser } from "../services/accountDBService.js";
import { getClientIp } from "../utils/getClientIp.js";
const router = Router();
const schema = joi.object({
service_id: joi.string().required(),
product_name: joi.string().allow("").optional(),
original_id: joi.string().required(),
original_password: joi.string().required(),
ip: joi.string().ip().required(),
});
function buildSuccessResponse(statusCode, idFlg, userId, authToken) {
return `<response>
<status>${statusCode}</status>
<id_flg>${idFlg}</id_flg>
<user_id>${userId}</user_id>
<auth_token>${authToken}</auth_token>
</response>`;
}
function buildErrorResponse(statusCode, idFlg) {
return `<response>
<status>${statusCode}</status>
<id_flg>${idFlg}</id_flg>
</response>`;
}
router.post(
"/cgi-bin/auth_rest_oem.cgi",
urlencoded({ extended: true }),
async (req, res) => {
try {
res.set({
"Content-Type": "text/xml",
Connection: "close",
});
const ip = getClientIp(req);
// Validate request
const { error } = schema.validate({ ...req.body, ip });
if (error) {
authLogger.warn(`[Auth] [JPN] Invalid request: ${error.message}`);
return res.send(buildErrorResponse(1, 0));
}
const { original_id, original_password } = req.body;
authLogger.info(
apiConfig.logIPAddresses === "true"
? `[Auth] [JPN] Account [${original_id}] is trying to login from [${ip}]`
: `[Auth] [JPN] Account [${original_id}] is trying to login`
);
// Authenticate user
const authResult = await authenticateUser(
original_id,
original_password,
ip
);
// Handle different authentication results
switch (authResult.status) {
case "LoginSuccess":
authLogger.info(
`[Auth] [JPN] Account [${original_id}] login success`
);
return res.send(
buildSuccessResponse(0, 0, authResult.authId, authResult.token)
);
case "Locked":
authLogger.warn(
`[Auth] [JPN] Account [${original_id}] login failed - account locked`
);
return res.send(buildErrorResponse(0, 2));
case "InvalidCredentials":
authLogger.warn(
`[Auth] [JPN] Account [${original_id}] login failed - invalid credentials`
);
return res.send(buildErrorResponse(1, 0));
case "TooManyAttempts":
authLogger.warn(
`[Auth] [JPN] Account [${original_id}] login failed - too many attempts`
);
return res.send(buildErrorResponse(8, 0));
case "AccountNotFound":
authLogger.warn(`[Auth] [JPN] Account [${original_id}] not found`);
return res.send(buildErrorResponse(2, 0));
default:
authLogger.error(
`[Auth] [JPN] Unknown authentication status: ${authResult.status}`
);
return res.send(buildErrorResponse(18, 0));
}
} catch (error) {
authLogger.error(`[Auth] [JPN] Error handling auth request: ${error.message}`);
return res.send(buildErrorResponse(18, 0));
}
}
);
export default router;

110
src/routes/authUsa.js Normal file
View file

@ -0,0 +1,110 @@
import { Router, text } from "express";
import { Parser } from "xml2js";
import joi from "joi";
import config from "../config.js";
const { apiConfig } = config;
import { authLogger } from "../utils/logger.js";
import { authenticateUser } from "../services/accountDBService.js";
import { getClientIp } from "../utils/getClientIp.js";
const router = Router();
const parser = new Parser({ explicitArray: false });
// body validation
const schema = joi.object({
"login-request": joi
.object({
account: joi.string().required(),
password: joi.string().required(),
game: joi.string().required(),
ip: joi.string().required(),
})
.required(),
});
function buildSuccessResponse(userId, userType, authToken) {
return `<userid>${userId}</userid>
<user-type>${userType}</user-type>
<auth_token>${authToken}</auth_token>
<status>success</status>`;
}
function buildErrorResponse(message) {
return `<status>failed</status>
<message>${message}</message>`;
}
router.post("/", text({ type: "*/xml" }), async (req, res) => {
try {
res.set({
"Content-Type": "text/xml",
Connection: "close",
});
const ip = getClientIp(req);
const xml = req.body;
const result = await parser.parseStringPromise(xml);
const { error, value } = schema.validate(result);
if (error) {
authLogger.info(`[Auth] [USA] Invalid login request: ${error.message}`);
return res.send(buildErrorResponse("ValidationError"));
}
const { "login-request": loginRequest } = value;
const { account, password } = loginRequest;
authLogger.info(
apiConfig.logIPAddresses === "true"
? `[Auth] [USA] Account [${account}] is trying to login from [${ip}]`
: `[Auth] [USA] Account [${account}] is trying to login`
);
// Authenticate user
const authResult = await authenticateUser(account, password, ip, true);
// Handle results
switch (authResult.status) {
case "LoginSuccess":
authLogger.info(`[Auth] [USA] Account [${account}] login success`);
return res.send(
buildSuccessResponse(authResult.authId, "F", authResult.token)
);
case "Locked":
authLogger.warn(
`[Auth] [USA] Account [${account}] login failed - account locked`
);
return res.send(buildErrorResponse(authResult.status));
case "InvalidCredentials":
authLogger.warn(
`[Auth] [USA] Account [${account}] login failed - invalid credentials`
);
return res.send(buildErrorResponse(authResult.status));
case "TooManyAttempts":
authLogger.warn(
`[Auth] [USA] Account [${account}] login failed - too many attempts`
);
return res.send(buildErrorResponse(authResult.status));
case "AccountNotFound":
authLogger.warn(`[Auth] [USA] Account [${account}] not found`);
return res.send(buildErrorResponse(authResult.status));
default:
authLogger.error(
`[Auth] [USA] Unknown authentication status: ${authResult.status}`
);
return res.send(buildErrorResponse(authResult.status));
}
} catch (error) {
authLogger.error(
`[Auth] [USA] Error handling auth request: ${error.message}`
);
return res.send(buildErrorResponse("ServerError"));
}
});
export default router;

View file

@ -1,138 +0,0 @@
const express = require('express');
const bodyParser = require('body-parser');
const xml2js = require('xml2js');
const sql = require('mssql');
const router = express.Router();
const { billingLogger } = require('../utils/logger');
const Joi = require('joi');
// Set up database connection
const { connAccount } = require('../utils/dbConfig');
// Define the validation schema for currency requests
const currencySchema = Joi.object({
userid: Joi.string().required(),
server: Joi.string().required(),
game: Joi.string().required(),
}).required();
// Define the validation schema for item purchase requests
const itemPurchaseSchema = Joi.object({
userid: Joi.string().required(),
server: Joi.string().required(),
charid: Joi.string().required(),
game: Joi.number().required(),
uniqueid: Joi.string().required(),
amount: Joi.number().required(),
itemid: Joi.string().required(),
count: Joi.number().required(),
}).required();
// Route for handling billing requests
router.post('/', bodyParser.text({
type: '*/xml'
}), async (req, res) => {
try {
const xml = req.body;
const result = await xml2js.parseStringPromise(xml, { explicitArray: false });
const name = result['currency-request'] ? 'currency-request' : 'item-purchase-request';
const request = result[name];
// Validate the request against the appropriate schema
const { error, value } = name === 'currency-request'
? currencySchema.validate(request)
: itemPurchaseSchema.validate(request);
if (error) {
billingLogger.info(`[Billing] Invalid request: ${error.message}`);
return res.status(400).send('<status>failed</status>');
}
const { userid, server, game, charid, uniqueid, amount, itemid, count } = value;
// Create a connection pool for the database
const pool = await connAccount;
switch (name) {
case 'currency-request':
const { recordset } = await pool
.request()
.input('UserId', sql.VarChar(50), userid)
.input('ServerId', sql.VarChar(50), server)
.execute('GetCurrency');
const row = recordset[0];
if (row && row.Result === 'Success') {
const response = `<result><balance>${row.Zen}</balance></result>`;
res.set('Content-Type', 'text/xml; charset=utf-8');
return res.send(response);
} else {
billingLogger.error(`[Billing] Currency request from user [${userid}] failed: ${row.Result}`);
return res.status(400).send('<status>failed</status>');
}
case 'item-purchase-request':
billingLogger.info(`[Billing] Received [${name}] from user [${userid}]`);
const { recordset: currencyRecordset } = await pool
.request()
.input('UserId', sql.VarChar(50), userid)
.input('ServerId', sql.VarChar(50), server)
.execute('GetCurrency');
const currencyRow = currencyRecordset[0];
if (currencyRow && currencyRow.Result === 'Success') {
const balance = currencyRow.Zen;
if (amount > 0) {
if (amount > balance) {
billingLogger.warn(`[Billing] Item purchase with id [${uniqueid}] from user [${userid}] failed. Not enough Zen [${balance}]. charid: [${charid}] itemid: [${itemid}] itemcount: [${count}] price: [${amount}]`);
return res.status(400).send('<status>failed</status>');
} else {
const newbalance = balance - amount;
await pool
.request()
.input('UserId', sql.VarChar(50), userid)
.input('ServerId', sql.VarChar(50), server)
.input('NewBalance', sql.BigInt, newbalance)
.execute('SetCurrency');
await pool
.request()
.input('userid', sql.VarChar(50), userid)
.input('charid', sql.VarChar(50), charid)
.input('uniqueid', sql.VarChar(50), uniqueid)
.input('amount', sql.BigInt, amount)
.input('itemid', sql.VarChar(50), itemid)
.input('itemcount', sql.Int, count)
.execute('SetBillingLog');
billingLogger.info(`[Billing] Item purchase with id [${uniqueid}] from user [${userid}] success. charid: [${charid}] itemid: [${itemid}] itemcount: [${count}] price: [${amount}]`);
billingLogger.info(`[Billing] [${userid}] Zen balance before purchase: [${balance}] | New zen balance: [${newbalance}]`);
const response = `<result><status>success</status><new-balance>${newbalance}</new-balance></result>`;
res.set('Content-Type', 'text/xml; charset=utf-8');
return res.send(response);
}
} else {
const response = `<result><balance>${currencyRow.Zen}</balance></result>`;
res.set('Content-Type', 'text/xml; charset=utf-8');
return res.send(response);
}
} else {
return res.status(400).send('<status>failed</status>');
}
default:
return res.status(400).send('<status>failed</status>');
}
} catch (error) {
billingLogger.error(`[Billing] Error handling request: ${error.message}`);
return res.status(500).send('<status>failed</status>');
}
});
module.exports = router;

117
src/routes/billingJpn.js Normal file
View file

@ -0,0 +1,117 @@
import { Router, urlencoded } from 'express';
import joi from 'joi';
import { billingLogger } from '../utils/logger.js';
import { validateCredentials, getCurrency, setCurrency } from '../services/accountDBService.js';
const router = Router();
function buildSuccessResponse(balance) {
return `<response>
<status>0</status>
<total_point>${balance}</total_point>
</response>`;
}
function buildErrorResponse(statusCode, errorMessage) {
return `<response>
<status>${statusCode}</status>
<error>${errorMessage}</error>
</response>`;
}
function extractItemId(itemCode) {
if (!itemCode || itemCode.length !== 17 || !itemCode.startsWith('rsty')) {
throw new Error('Invalid item_code format');
}
return parseInt(itemCode.substring(4, 14), 10);
}
// Validation Schemas
const baseSchema = {
product_name: joi.string().allow('').optional(),
original_id: joi.string().required(),
original_password: joi.string().required(),
auto_charge_exec: joi.number().integer().optional()
};
const purchaseSchema = joi.object({
...baseSchema,
move_point: joi.number().integer().required(),
move_kind: joi.string().valid('06').required(),
item_code: joi.string().pattern(/^rsty\d{13}$/).required()
});
const currencySchema = joi.object(baseSchema);
async function handleBalanceRequest(req, res) {
try {
res.set({
'Content-Type': 'text/xml',
'Connection': 'close'
});
const { error, value } = currencySchema.validate(req.body);
if (error) throw new Error(`Invalid request: ${error.message}`);
const { original_id, original_password } = value;
if (!await validateCredentials(original_id, original_password)) {
throw new Error('Invalid credentials');
}
const balance = await getCurrency(original_id);
return res.send(buildSuccessResponse(balance));
} catch (error) {
billingLogger.error(`[Billing] [JPN] Balance request error: ${error.message}`);
return res.send(buildErrorResponse(1, error.message.includes('Invalid') ? error.message : 'Invalid request'));
}
}
async function handlePurchaseRequest(req, res) {
try {
res.set({
'Content-Type': 'text/xml',
'Connection': 'close'
});
const { error, value } = purchaseSchema.validate(req.body);
if (error) throw new Error(`Invalid request: ${error.message}`);
const { original_id, original_password, move_point, item_code } = value;
if (!await validateCredentials(original_id, original_password)) {
throw new Error('Invalid credentials');
}
const currentBalance = await getCurrency(original_id);
const newBalance = currentBalance + move_point;
if (newBalance < 0) {
throw new Error('Insufficient balance');
}
const item_id = extractItemId(item_code);
await setCurrency(original_id, newBalance);
billingLogger.info(`[Billing] [JPN] Purchase processed for [${original_id}]
Shop Item: ${item_id}
Price: ${move_point}
Balance: ${currentBalance} ${newBalance}`);
return res.send(buildSuccessResponse(newBalance));
} catch (error) {
billingLogger.error(`[Billing] [JPN] Purchase error: ${error.message}`);
return res.send(buildErrorResponse(
error.message.includes('Insufficient') ? 1 : 18,
error.message.includes('Invalid') ? error.message : 'Transaction failed'
));
}
}
// Routes
router.post('/S1/ApiPointTotalGetS.php', urlencoded({ extended: true }), handleBalanceRequest);
router.post('/S1/ApiPointMoveS.php', urlencoded({ extended: true }), handlePurchaseRequest);
export default router;

146
src/routes/billingUsa.js Normal file
View file

@ -0,0 +1,146 @@
import { Router } from "express";
import bodyParser from "body-parser";
import { parseStringPromise } from "xml2js";
const router = Router();
import { billingLogger } from "../utils/logger.js";
import Joi from "joi";
import {
getCurrency,
setCurrency,
logBillingTransaction,
} from "../services/accountDBService.js";
// Schemas
const currencySchema = Joi.object({
userid: Joi.string().required(),
server: Joi.string().required(),
game: Joi.string().required(),
}).required();
const itemPurchaseSchema = Joi.object({
userid: Joi.string().required(),
server: Joi.string().required(),
charid: Joi.string().required(),
game: Joi.number().required(),
uniqueid: Joi.string().required(),
amount: Joi.number().required(),
itemid: Joi.string().required(),
count: Joi.number().required(),
}).required();
// XML response builders
function buildSuccessResponse(balance) {
return `<result><balance>${balance}</balance></result>`;
}
function buildPurchaseSuccessResponse(newBalance) {
return `<result>
<status>success</status>
<new-balance>${newBalance}</new-balance>
</result>`;
}
function buildErrorResponse(message) {
return `<status>failed</status><message>${message}</message>`;
}
// Currency request handler
async function handleCurrencyRequest(data, res) {
const { error, value } = currencySchema.validate(data);
if (error) {
billingLogger.warn(`[Billing] Invalid currency request: ${error.message}`);
return res.status(400).send(buildErrorResponse("Invalid request"));
}
const { userid, server } = value;
try {
const currency = await getCurrency(userid);
return res.send(buildSuccessResponse(currency));
} catch (err) {
billingLogger.error(
`[Billing] Balance request error for [${userid}]: ${err.message}`
);
return res.status(400).send(buildErrorResponse("Failed to get balance"));
}
}
// Item purchase handler
async function handleItemPurchaseRequest(data, res) {
const { error, value } = itemPurchaseSchema.validate(data);
if (error) {
billingLogger.warn(`[Billing] Invalid purchase request: ${error.message}`);
return res.status(400).send(buildErrorResponse("Invalid request"));
}
const { userid, server, charid, uniqueid, amount, itemid, count } = value;
billingLogger.info(
`[Billing] Processing purchase [${uniqueid}] for user [${userid}]`
);
try {
const balance = await getCurrency(userid);
if (amount <= 0) {
return res.send(buildSuccessResponse(balance));
}
if (amount > balance) {
billingLogger.warn(
`[Billing] Insufficient funds for user [${userid}]: Has ${balance}, needs ${amount}`
);
return res.status(400).send(buildErrorResponse("Insufficient funds"));
}
const newBalance = balance - amount;
await setCurrency(userid, newBalance);
await logBillingTransaction({
userId: userid,
charId: charid,
uniqueId: uniqueid,
amount,
itemId: itemid,
count,
});
billingLogger.info(`[Billing] Purchase successful for user [${userid}]
Purchase ID: ${uniqueid}
CharacterId: [${charid}]
Item: ${itemid} (x${count})
Price: ${amount}
Balance: ${balance} ${newBalance}`);
return res.send(buildPurchaseSuccessResponse(newBalance));
} catch (err) {
billingLogger.error(
`[Billing] Purchase failed for user [${userid}]: ${err.message}`
);
return res.status(400).send(buildErrorResponse("Transaction failed"));
}
}
router.post("/", bodyParser.text({ type: "*/xml" }), async (req, res) => {
res.set({
"Content-Type": "text/xml",
Connection: "close",
});
try {
const xml = req.body;
const result = await parseStringPromise(xml, { explicitArray: false });
if (result["currency-request"]) {
return handleCurrencyRequest(result["currency-request"], res);
} else if (result["item-purchase-request"]) {
return handleItemPurchaseRequest(result["item-purchase-request"], res);
} else {
return res.status(400).send(buildErrorResponse("Invalid request type"));
}
} catch (err) {
billingLogger.error(`[Billing] Error handling billing request: ${err.message}`);
return res.status(500).send(buildErrorResponse("Internal server error"));
}
});
export default router;

View file

@ -1,58 +1,103 @@
const express = require('express');
const router = express.Router();
const net = require('net');
const { logger } = require('../utils/logger');
import { Router } from 'express';
const router = Router();
import { Socket } from 'net';
import config from '../config.js';
const { ports, ips, logger } = config;
import { create } from 'xmlbuilder2';
// Define the gateway route
router.get('/', (req, res) => {
const ip = process.env.GATESERVER_IP;
const port = process.env.GATESERVER_PORT || '50001';
// Constants
const SOCKET_TIMEOUT = 2000;
const GATEWAY_STATUS = {
ONLINE: 'online',
OFFLINE: 'offline'
};
// Generate the XML content with the IP and port values
const xml = `<?xml version="1.0" encoding="ISO-8859-1"?>
<network>
<gateserver ip="${ip}" port="${port}" />
</network>`;
/**
* Build XML response for gateway info
*/
function buildGatewayXml() {
return create({ version: '1.0', encoding: 'ISO-8859-1' })
.ele('network')
.ele('gateserver')
.att('ip', ips.gate)
.att('port', ports.gate)
.up()
.end({ prettyPrint: true });
}
res.set('Content-Type', 'application/xml');
/**
* Build gateway info route response
*/
function buildGatewayInfo(req) {
const baseUrl = `${req.protocol}://${req.headers.host}`;
return `1|${baseUrl}/launcher/GetGatewayAction|${baseUrl}/launcher/GetGatewayAction|`;
}
res.send(xml);
});
/**
* Check gateway server status via socket connection
*/
async function checkGatewayStatus() {
return new Promise((resolve) => {
const socket = new Socket();
socket.setTimeout(SOCKET_TIMEOUT);
// Define the gateway info route
router.get('/info', (req, res) => {
const gatewayRoute = `1|${req.protocol}://${req.headers.host}/serverApi/gateway|${req.protocol}://${req.headers.host}/serverApi/gateway|`;
res.send(gatewayRoute);
});
// Define the gateway status route
router.get('/status', async (req, res) => {
const ip = process.env.GATESERVER_IP;
const port = process.env.GATESERVER_PORT || '50001';
const timeout = 2000;
// Create a new socket and connect to the gateserver
const socket = new net.Socket();
socket.setTimeout(timeout);
socket.connect(port, ip);
// Handle the socket events to check the connection status
socket.on('connect', () => {
logger.info(`[Gateway] Connection attempt success from IP: ${req.ip}`);
res.status(200).json({ status: 'online' });
socket.destroy();
resolve({ status: GATEWAY_STATUS.ONLINE });
});
socket.on('timeout', () => {
logger.warn(`[Gateway] Connection attempt timeout from IP: ${req.ip}`);
res.status(408).json({ status: 'offline' });
socket.destroy();
resolve({ status: GATEWAY_STATUS.OFFLINE, code: 408 });
});
socket.on('error', () => {
logger.error(`[Gateway] Connection failed from IP: ${req.ip}`);
res.status(503).json({ status: 'offline' });
socket.destroy();
resolve({ status: GATEWAY_STATUS.OFFLINE, code: 503 });
});
socket.connect(ports.gate, ips.gate);
});
}
// Main gateway route
router.get('/', (req, res) => {
try {
res.set('Content-Type', 'application/xml');
res.send(buildGatewayXml());
logger.debug(`[Gateway] XML served to ${req.ip}`);
} catch (error) {
logger.error(`[Gateway] XML generation failed: ${error.message}`);
res.status(500).send('Internal Server Error');
}
});
module.exports = router;
// Gateway info route
router.get('/info', (req, res) => {
try {
res.send(buildGatewayInfo(req));
logger.debug(`[Gateway] Info served to ${req.ip}`);
} catch (error) {
logger.error(`[Gateway] Info generation failed: ${error.message}`);
res.status(500).send('Internal Server Error');
}
});
// Gateway status route
router.get('/status', async (req, res) => {
try {
const { status, code } = await checkGatewayStatus();
logger[status === GATEWAY_STATUS.ONLINE ? 'info' : 'warn'](
`[Gateway] Status check from ${req.ip}: ${status}`
);
res.status(status === GATEWAY_STATUS.ONLINE ? 200 : code || 503)
.json({ status });
} catch (error) {
logger.error(`[Gateway] Status check failed: ${error.message}`);
res.status(500).json({ status: GATEWAY_STATUS.OFFLINE });
}
});
export default router;

View file

@ -1,108 +1,60 @@
const sql = require('mssql');
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { logger, accountLogger } = require('../../utils/logger');
const { sendPasswordChangedEmail } = require('../../mailer/mailer');
const Joi = require('joi');
import { Router } from "express";
const router = Router();
import joi from "joi";
import { logger, accountLogger } from "../../utils/logger.js";
import { changeAccountPassword } from "../../services/accountDBService.js";
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
// Joi schema for request body validation
const schema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
verification_code: Joi.string().pattern(new RegExp('^[0-9]+$')).required()
const schema = joi.object({
email: joi.string().email().required(),
password: joi.string().required(),
verificationCode: joi.string().pattern(new RegExp("^[0-9]+$")).required(),
});
// Route for registering an account
router.post('/', async (req, res) => {
router.post("/", async (req, res) => {
try {
// Validate request body
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
return res.status(400).json({
success: false,
result: "ValidationError",
message: error.details[0].message,
});
}
const email = value.email;
const password = value.password;
const verificationCode = value.verification_code;
const { email, password, verificationCode } = value;
// Use a prepared statement to get the verification code
const pool = await connAccount;
const request = pool.request();
request.input('Email', sql.VarChar, email);
request.input('VerificationCode', sql.VarChar, verificationCode);
request.input('VerificationCodeType', sql.VarChar, 'Password');
const inputResult = await request.execute('GetVerificationCode');
const inputRow = inputResult.recordset[0];
if (inputRow && inputRow.Result === 'ValidVerificationCode') {
// Use a prepared statement to retrieve the account information
const pool = await connAccount;
const request = pool.request();
request.input('Identifier', sql.VarChar, email);
const getResult = await request.execute('GetAccount');
const getRow = getResult.recordset[0];
if (getRow && getRow.Result === 'AccountExists') {
const windyCode = getRow.WindyCode
const hash = getRow.AccountPwd;
// Verify the password
const md5_password = crypto
.createHash('md5')
.update(windyCode + password)
.digest('hex');
const password_verify_result = await bcrypt.compare(
md5_password,
hash
const changeAccountPasswordStatus = await changeAccountPassword(
email,
password,
verificationCode
);
if (password_verify_result === true) {
return res.status(400).send('SamePassword');
} else {
const passwordHash = await bcrypt.hash(md5_password, 10);
// Use a prepared statement to update the password
const pool = await connAccount;
const request = pool.request();
request.input('Email', sql.VarChar, email);
request.input('AccountPwd', sql.VarChar, passwordHash);
const updateResult = await request.execute('UpdateAccountPassword');
const updateRow = updateResult.recordset[0];
if (updateRow && updateRow.Result === 'PasswordChanged') {
accountLogger.info(`[Account] Password for [${windyCode}] changed successfully`);
sendPasswordChangedEmail(email, windyCode);
const pool = await connAccount;
const request = pool.request();
request.input('Email', sql.VarChar, email);
const clearResult = await request.execute('ClearVerificationCode');
const clearRow = clearResult.recordset[0];
return res.status(200).send('PasswordChanged');
} else {
accountLogger.info(`[Account] Password change for [${windyCode}] failed: ${row.Result}`);
return res.status(400).send(updateRow.Result);
switch (changeAccountPasswordStatus) {
case "PasswordChanged":
accountLogger.info(
`[Account] Account [${email}] password changed successfully`
);
return res.status(200).json({
success: true,
result: changeAccountPasswordStatus,
});
default:
accountLogger.info(
`[Account] Account [${email}] password change failed: ${changeAccountPasswordStatus}`
);
return res.status(200).json({
success: false,
result: changeAccountPasswordStatus,
});
}
}
} else {
return res.status(400).send(getRow.Result);
}
} else {
return res.status(400).send(inputRow.Result);
}
} catch (error) {
logger.error('[Account] A error ocourred: ' + error.message);
return res.status(500).send('A error ocourred. Please try again later.');
logger.error(`Account password change failed: ${error.message}`);
return res.status(500).json({
result: "ServerError",
message: "A server error occurred. Please try again later.",
});
}
});
module.exports = router;
export default router;

View file

@ -1,53 +1,63 @@
const sql = require('mssql');
const express = require('express');
const router = express.Router();
const { logger } = require('../../utils/logger');
const Joi = require('joi');
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
import { Router } from "express";
const router = Router();
import joi from "joi";
import { logger } from "../../utils/logger.js";
import { verifyCode } from "../../services/accountDBService.js";
// Joi schema for request body validation
const schema = Joi.object({
email: Joi.string().email().required(),
verification_code_type: Joi.string().required(),
verification_code: Joi.string().pattern(new RegExp('^[0-9]+$')).required()
const schema = joi.object({
email: joi.string().email().required(),
verificationCodeType: joi.string().required(),
verificationCode: joi.string().pattern(new RegExp("^[0-9]+$")).required(),
});
// Route for registering an account
router.post('/', async (req, res) => {
router.post("/", async (req, res) => {
try {
// Validate request body
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
}
const email = req.body.email;
const verificationCode = req.body.verification_code;
const verificationCodeType = req.body.verification_code_type;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).send('InvalidEmailFormat');
return res.status(400).json({
success: false,
result: "ValidationError",
message: error.details[0].message,
});
}
if (!/^\d+$/.test(verificationCode)) {
return res.status(400).send('InvalidVerificationCodeFormat');
const {
email,
verificationCode: verificationCode,
verificationCodeType: verificationCodeType,
} = value;
// Verify code with database
const verificationResult = await verifyCode(
email,
verificationCode,
verificationCodeType
);
if (verificationResult !== "ValidVerificationCode") {
logger.info(
`[Account] Verification failed for ${email}. Status: ${verificationResult}`
);
return res.status(200).json({
success: false,
result: verificationResult
});
}
// Use a prepared statement to check verification code
const pool = await connAccount;
const request = pool.request();
request.input('Email', sql.VarChar, email);
request.input('VerificationCode', sql.VarChar, verificationCode);
request.input('VerificationCodeType', sql.VarChar, verificationCodeType);
const result = await request.execute('GetVerificationCode');
const row = result.recordset[0];
return res.status(200).send(row.Result);
logger.info(
`[Account] Verification successful for ${email}. Status: ${verificationResult}`
);
return res.status(200).json({
success: true,
result: verificationResult,
});
} catch (error) {
logger.error('Database query failed: ' + error.message);
return res.status(500).send('A error ocourred. Please try again later.');
logger.error(`Verification failed: ${error.message}`);
return res.status(500).json({
result: "ServerError",
message: "A server error occurred. Please try again later.",
});
}
});
module.exports = router;
export default router;

View file

@ -1,41 +1,120 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { logger } = require('../../utils/logger');
import { Router } from "express";
import { readFile, existsSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { logger } from "../../utils/logger.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
const router = Router();
// Endpoint to get the launcher version from the launcher_info.ini file
router.get('/getLauncherVersion', (req, res) => {
const launcherInfoPath = path.join(__dirname, '..', '..', '..', 'public', 'launcher', 'launcher_update', 'launcher_info.ini');
fs.readFile(launcherInfoPath, 'utf8', (err, data) => {
router.get("/getLauncherVersion", (req, res) => {
try {
const launcherInfoPath = path.join(
__dirname,
"..",
"..",
"..",
"public",
"launcher",
"launcher_update",
"launcher_info.ini"
);
readFile(launcherInfoPath, "utf8", (err, data) => {
try {
if (err) {
console.error(err);
return res.status(500).send('Error reading launcher_info.ini');
logger.error(err);
return res.status(400).json({
result: "FileReadError",
message: "Error reading launcher_info.ini file",
});
}
const versionRegex = /version=(.*)/i;
const match = data.match(versionRegex);
if (match) {
const launcherVersion = match[1];
return res.json({ version: launcherVersion });
}
return res.status(500).send('Invalid launcher_info.ini format');
return res.status(200).json({
version: launcherVersion,
});
}
return res.status(400).json({
result: "VersionNotFound",
message: "Version not found in launcher_info.ini file",
});
} catch (error) {
logger.error(`[getLauncherVersion] Processing error: ${error}`);
return res.status(500).json({
result: "InternalError",
message: "An error occurred while processing the file",
});
}
});
} catch (error) {
logger.error(`[getLauncherVersion] Initialization error: ${error}`);
return res.status(500).json({
result: "InternalError",
message: "An error occurred while initializing the version check",
});
}
});
// Endpoint to download the new launcher version from the launcher_update folder
router.post('/updateLauncherVersion', (req, res) => {
const launcherUpdatePath = path.join(__dirname, '..', '..', '..', 'public', 'launcher', 'launcher_update');
router.post("/updateLauncherVersion", (req, res) => {
try {
const launcherUpdatePath = path.join(
__dirname,
"..",
"..",
"..",
"public",
"launcher",
"launcher_update"
);
const version = req.body.version;
if (!req.body.version) {
return res.status(400).send('Missing version parameter');
}
const file = path.join(launcherUpdatePath, `launcher_${version}.zip`);
if (!fs.existsSync(file)) {
return res.status(404).send(`File ${file} not found`);
logger.error(`[Launcher Updater] File ${file} not found`);
if (!version) {
return res.status(400).json({
result: "MissingVersion",
message: "Missing version parameter",
});
}
const fileName = `launcher_${version}.zip`;
const file = path.join(launcherUpdatePath, fileName);
if (!existsSync(file)) {
logger.error(`[Launcher Updater] File ${fileName} not found`);
return res.status(404).json({
result: "FileNotFound",
message: `File ${fileName} not found on the server`,
});
}
res.download(file, (err) => {
if (err) {
logger.error(`[updateLauncherVersion] Download error: ${err}`);
if (!res.headersSent) {
return res.status(500).json({
result: "DownloadError",
message: "Error occurred while downloading the file",
});
}
}
});
} catch (error) {
logger.error(`[updateLauncherVersion] Error: ${error}`);
if (!res.headersSent) {
return res.status(500).json({
result: "InternalError",
message: "An unexpected error occurred",
});
}
}
res.download(file);
});
module.exports = router;
export default router;

View file

@ -1,99 +1,63 @@
const sql = require('mssql');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const express = require('express');
const router = express.Router();
const { logger, accountLogger } = require('../../utils/logger');
const Joi = require('joi');
import { Router } from "express";
const router = Router();
import joi from "joi";
import config from "../../config.js";
const { apiConfig } = config;
import { logger } from "../../utils/logger.js";
import { getClientIp } from "../../utils/getClientIp.js";
import { authenticateUser } from "../../services/accountDBService.js";
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
// Define the validation schema for the request body
const schema = Joi.object({
account: Joi.string().required(),
password: Joi.string().required(),
const schema = joi.object({
account: joi.string().required(),
password: joi.string().min(8).max(16).required(),
});
router.post('/', async (req, res) => {
router.post("/", async (req, res) => {
try {
// Validate the request body against the schema
// Validate request
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
return res.status(400).json({
result: "InvalidRequest",
message: error.details[0].message,
});
}
const account = req.body.account;
const password = req.body.password;
const userIp = req.ip;
const { account, password } = value;
const ip = getClientIp(req);
// Check the format of the account identifier
if (
!/^[a-z0-9_-]{6,50}$/.test(account) &&
!/^[\w\d._%+-]+@[\w\d.-]+\.[\w]{2,}$/i.test(account)
) {
return res.status(400).json({ Result: 'InvalidUsernameFormat' });
}
// Use a prepared statement to retrieve the account information
const pool = await connAccount;
const request = pool.request();
request.input('Identifier', sql.VarChar, account);
const result = await request.execute('GetAccount');
const row = result.recordset[0];
if (row && row.Result === 'AccountExists') {
const windyCode = row.WindyCode;
const hash = row.AccountPwd;
// Verify the password
const md5_password = crypto
.createHash('md5')
.update(windyCode + password)
.digest('hex');
const password_verify_result = await bcrypt.compare(
md5_password,
hash
logger.info(
apiConfig.logIPAddresses === "true"
? `[Launcher Login] Account [${account}] is trying to login from [${ip}]`
: `[Launcher Login] Account [${account}] is trying to login`
);
// Authenticate user
const authResult = await authenticateUser(account, password, ip);
const authRequest = pool.request();
authRequest.input('Identifier', sql.VarChar, account);
authRequest.input(
'password_verify_result',
sql.Bit,
password_verify_result
);
authRequest.input('LastLoginIP', sql.VarChar, userIp);
const authResult = await authRequest.execute('AuthenticateUser');
const authRow = authResult.recordset[0];
if (authRow && authRow.Result === 'LoginSuccess') {
accountLogger.info(
`[Account] Launcher Login: Account [${windyCode}] successfully logged in from [${userIp}]`
if (!authResult || authResult.status !== "LoginSuccess") {
logger.warn(
`[Launcher Login] Authentication failed for user [${account}]: ${authResult?.status}`
);
return res.status(200).json({
Result: authRow.Result,
Token: authRow.Token,
WindyCode: authRow.WindyCode,
result: authResult?.status || "AuthenticationFailed",
});
} else {
accountLogger.info(
`[Account] Launcher Login: Account [${windyCode}] login failed: ${authRow.Result} `
}
logger.info(
`[Launcher Login] Authentication successful for user [${account}]`
);
return res.status(400).json({
Result: authRow.Result,
return res.status(200).json({
result: authResult.status,
token: authResult.token,
windyCode: account,
});
}
} else {
return res.status(400).json({ Result: 'AccountNotFound' });
}
} catch (error) {
logger.error(
'[Account] Launcher Login: Database query failed: ' + error.message
);
return res.status(500).send('Login failed. Please try again later.');
logger.error(`[Launcher Login] Authentication failed: ${error.message}`);
return res.status(500).json({
result: "ServerError",
message: "A server error occurred. Please try again later.",
});
}
});
module.exports = router;
export default router;

View file

@ -1,74 +1,52 @@
const sql = require('mssql');
const express = require('express');
const router = express.Router();
const { logger, accountLogger } = require('../../utils/logger');
const { sendPasswordResetEmail } = require('../../mailer/mailer');
const Joi = require('joi');
import { Router } from "express";
const router = Router();
import { logger, accountLogger } from "../../utils/logger.js";
import joi from "joi";
import { sendPasswordVerificationEmail } from "../../services/accountDBService.js";
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
// Joi schema for request body validation
const schema = Joi.object({
email: Joi.string().email().required()
const schema = joi.object({
email: joi.string().email().required(),
});
// Route for registering an account
router.post('/', async (req, res) => {
// Route for sending verification email for password reset
router.post("/", async (req, res) => {
try {
// Validate request body
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
return res.status(400).json({
success: false,
result: "ValidationError",
message: error.details[0].message,
});
}
const email = req.body.email;
const { email } = value;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
logger.info('Invalid email format');
return res.status(400).send('InvalidEmailFormat');
}
// Use a prepared statement to retrieve the account information
const pool = await connAccount;
const request = pool.request();
request.input('Identifier', sql.VarChar, email);
const result = await request.execute('GetAccount');
const row = result.recordset[0];
if (row && row.Result === 'AccountExists') {
const emailAdress = row.Email;
const windycode = row.WindyCode;
const verificationCode = Math.floor(10000 + Math.random() * 90000).toString();
const expirationTime = new Date(Date.now() + 600000).toISOString(); // 10 minutes from now
// Prepare the second statement to insert the verification code information
const insertRequest = pool.request();
insertRequest.input('Email', sql.VarChar, email);
insertRequest.input('VerificationCode', sql.VarChar, verificationCode);
insertRequest.input('ExpirationTime', sql.DateTime, expirationTime);
const insertResult = await insertRequest.execute('SetPasswordVerificationCode');
const insertRow = insertResult.recordset[0];
if (insertRow && insertRow.Result === 'Success') {
// Send verification code email
sendPasswordResetEmail(email, verificationCode);
return res.status(200).send('EmailSent');
}
else {
accountLogger.error(`[Account] Failed to insert verification code for email: ${email}`);
return res.status(500).send(insertRow.Result);
}
} else if (row && row.Result === 'AccountNotFound') {
return res.status(400).send('AccountNotFound');
} else {
return res.status(500).send(row.Result);
const sendEmailStatus = await sendPasswordVerificationEmail(email);
if (sendEmailStatus !== "Success") {
accountLogger.info(
`[Account] Password reset request failed to [${email}]. Status: ${sendEmailStatus}`
);
return res.status(200).json({
success: true,
result: sendEmailStatus,
});
}
accountLogger.info(
`[Account] Password reset request sent to [${email}]. Status: ${sendEmailStatus}`
);
return res.status(200).json({
success: true,
result: sendEmailStatus,
});
} catch (error) {
logger.error('[Account] Database query failed: ' + error.message);
return res.status(500).send('A error ocourred. Please try again later.');
logger.error(`[Account] Password reset request failed: ${error.message}`);
return res.status(500).json({
result: 'ServerError',
message: 'A server error occurred. Please try again later.'
});
}
});
module.exports = router;
export default router;

View file

@ -1,74 +0,0 @@
const sql = require('mssql');
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { logger, accountLogger } = require('../../utils/logger');
const { sendConfirmationEmail } = require('../../mailer/mailer');
const Joi = require('joi');
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
// Joi schema for validating request data
const schema = Joi.object({
windyCode: Joi.string().alphanum().min(6).max(16).required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
});
// Route for registering an account
router.post('/', async (req, res) => {
try {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
}
const windyCode = value.windyCode;
const email = value.email;
const password = value.password;
const userIp = req.ip;
if (
!/^[a-z0-9_-]{6,50}$/.test(windyCode) &&
!/^[\w\d._%+-]+@[\w\d.-]+\.[\w]{2,}$/i.test(email)
) {
return res.status(400).send('InvalidUsernameFormat');
}
const md5_password = crypto.createHash('md5').update(windyCode + password).digest('hex'); // Generate MD5 hash
const passwordHash = await bcrypt.hash(md5_password, 10);
// Use a prepared statement to create the account
const pool = await connAccount;
const request = pool.request();
request.input('WindyCode', sql.VarChar, windyCode);
request.input('AccountPwd', sql.VarChar, passwordHash);
request.input('Email', sql.VarChar, email);
request.input('RegisterIP', sql.VarChar, userIp);
const result = await request.execute('CreateAccount');
const row = result.recordset[0];
if (row && row.Result === 'AccountCreated') {
accountLogger.info(`[Account] Account [${windyCode}] created successfully`);
sendConfirmationEmail(email, windyCode);
const clearRequest = pool.request();
clearRequest.input('Email', sql.VarChar, email);
const clearResult = await clearRequest.execute('ClearVerificationCode');
const clearRow = clearResult.recordset[0];
return res.status(200).send('Success');
} else {
accountLogger.error(`[Account] Account [${windyCode}] creation failed: ${row.Result}`);
return res.status(400).send(row.Result);
}
} catch (error) {
logger.error('[Account] Database query failed: ' + error.message);
return res.status(500).send('A error ocourred. Please try again later.');
}
});
module.exports = router;

View file

@ -0,0 +1,65 @@
import { Router } from "express";
const router = Router();
import joi from "joi";
import { logger, accountLogger } from "../../utils/logger.js";
import { createAccount } from "../../services/accountDBService.js";
import { getClientIp } from "../../utils/getClientIp.js";
const schema = joi.object({
username: joi.string().alphanum().lowercase().min(6).max(16).required(),
email: joi.string().email().required(),
password: joi.string().min(8).max(16).required(),
verificationCode: joi.string().pattern(new RegExp("^[0-9]+$")).required(),
});
// Route for registering an account
router.post("/", async (req, res) => {
try {
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
result: "ValidationError",
message: error.details[0].message,
});
}
const ip = getClientIp(req);
const { username, email, password, verificationCode } = value;
const createAccountStatus = await createAccount(
username,
email,
password,
ip,
verificationCode
);
switch (createAccountStatus) {
case "AccountCreated":
accountLogger.info(
`[Account] Account [${username}] created successfully.`
);
return res.status(200).json({
success: true,
result: createAccountStatus,
});
default:
accountLogger.info(
`[Account] Account [${username}] creation failed - ${createAccountStatus}`
);
return res.status(200).json({
success: false,
result: createAccountStatus,
});
}
} catch (error) {
logger.error("[Account] Account creation failed: " + error.message);
return res.status(500).json({
result: "ServerError",
message: "A server error occurred. Please try again later.",
});
}
});
export default router;

View file

@ -1,68 +1,54 @@
// Load environment variables
const env = require('../../utils/env');
import { Router } from "express";
const router = Router();
import { logger, accountLogger } from "../../utils/logger.js";
import joi from "joi";
import { sendAccountVerificationEmail } from "../../services/accountDBService.js";
const sql = require('mssql');
const express = require('express');
const router = express.Router();
const { logger } = require('../../utils/logger');
const { sendVerificationEmail } = require('../../mailer/mailer');
const Joi = require('joi');
// Set up database connection
const { connAccount } = require('../../utils/dbConfig');
// Joi schema for request body validation
const schema = Joi.object({
email: Joi.string().email().required()
const schema = joi.object({
email: joi.string().email().required(),
});
// Route for registering an account
router.post('/', async (req, res) => {
// Route for sending verification email for account creation
router.post("/", async (req, res) => {
try {
// Validate request body
const { error, value } = schema.validate(req.body);
if (error) {
return res.status(400).send(error.details[0].message);
return res.status(400).json({
success: false,
result: "ValidationError",
message: error.details[0].message,
});
}
const email = req.body.email;
const { email } = value;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).send('InvalidEmailFormat');
}
// Use a prepared statement to retrieve the account information
const pool = await connAccount;
const request = pool.request();
request.input('Identifier', sql.VarChar, email);
const result = await request.execute('GetAccount');
const row = result.recordset[0];
if (row && row.Result === 'AccountNotFound') {
const verificationCode = Math.floor(10000 + Math.random() * 90000).toString();
const timeZone = process.env.TZ;
// Set the expiration time 10 minutes from now in the specified timezone
const expirationTime = new Date(Date.now() + 600000).toLocaleString('en-US', { timeZone });
// Prepare the second statement to insert the verification code information
const insertRequest = pool.request();
insertRequest.input('Email', sql.VarChar, email);
insertRequest.input('VerificationCode', sql.VarChar, verificationCode);
insertRequest.input('ExpirationTime', sql.DateTime, expirationTime);
const insertResult = await insertRequest.execute('SetAccountVerificationCode');
const insertRow = insertResult.recordset[0];
// Send verification code email
sendVerificationEmail(email, verificationCode);
return res.status(200).send('EmailSent');
} else {
return res.status(400).send(row.Result);
const sendEmailStatus = await sendAccountVerificationEmail(email);
if (sendEmailStatus !== "Success") {
accountLogger.info(
`[Account] Verification email sending failed to ${email}. Sending status: ${sendEmailStatus}`
);
return res.status(200).json({
success: false,
result: sendEmailStatus,
});
}
accountLogger.info(
`[Account] Verification email sent to ${email}. Sending status: ${sendEmailStatus}`
);
return res.status(200).json({
success: true,
result: sendEmailStatus,
});
} catch (error) {
logger.error('[Account] Database query failed: ' + error.message);
return res.status(500).send('A error ocourred. Please try again later.');
logger.error(
`[Account] Verification email sending failed: ${error.message}`
);
return res.status(500).json({
result: 'ServerError',
message: 'A server error occurred. Please try again later.'
});
}
});
module.exports = router;
export default router;

View file

@ -1,47 +1,96 @@
const express = require('express');
const router = express.Router();
const NodeCache = require('node-cache');
const { logger } = require('../utils/logger');
const sql = require('mssql');
const { authDBConfig } = require('../utils/dbConfig.js');
import { Router } from 'express';
import sql from 'mssql';
const router = Router();
import NodeCache from 'node-cache';
import { logger } from '../utils/logger.js';
import { authDBConfig } from '../utils/dbConfig.js';
import { fetchOnlineCount } from "../services/authDBService.js";
// Set up the cache
const cache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
const CACHE_KEY = 'onlineCount';
const CACHE_TTL = 60; // 1 minute
const CHECK_PERIOD = 120; // 2 minutes
// Route for getting the count of online players
router.get('/', async (req, res) => {
// Cache configuration
const cache = new NodeCache({
stdTTL: CACHE_TTL,
checkperiod: CHECK_PERIOD,
useClones: false
});
// Database connection pool
let pool;
let poolReady = (async () => {
try {
// Check if the count exists in the cache
const cacheKey = 'onlineCount';
let count = cache.get(cacheKey);
pool = await new sql.ConnectionPool(authDBConfig).connect();
} catch (error) {
logger.error('Failed to create database connection pool:', error);
process.exit(1);
}
})();
/**
* Gets online count, using cache when possible
*/
async function getOnlineCount() {
try {
// Try to get from cache first
let count = cache.get(CACHE_KEY);
if (count === undefined) {
// Count not found in cache, fetch it from the database
const connAuth = new sql.ConnectionPool(authDBConfig);
await connAuth.connect();
const request = connAuth.request();
// Declare the @online parameter and set its value to 1
request.input('online', sql.Int, 1);
const result = await request.query('SELECT COUNT(*) AS OnlineCount FROM AuthTable WHERE online = @online');
count = result.recordset[0].OnlineCount;
// Store the count in the cache
cache.set(cacheKey, count);
// Close the database connection
await connAuth.close();
logger.debug('Cache miss for online count, querying database');
count = await fetchOnlineCount(pool);
cache.set(CACHE_KEY, count);
} else {
logger.debug('Online count retrieved from cache');
}
// Return the count as the response
return res.status(200).json({ count });
return count;
} catch (error) {
logger.error('Database query failed: ' + error.message);
return res.status(500).send('Database query failed. Please try again later.');
// If we have a cached value, return it even if the query fails
const cached = cache.get(CACHE_KEY);
if (cached !== undefined) {
logger.warn('Using cached online count due to database error');
return cached;
}
throw error;
}
}
// Route for getting online players count
router.get('/', async (req, res) => {
try {
if (!pool) await poolReady;
const count = await getOnlineCount();
// Set cache-control headers
res.set('Cache-Control', `public, max-age=${CACHE_TTL}`);
return res.status(200).json({
count,
cached: cache.has(CACHE_KEY),
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Failed to get online count:', error);
return res.status(500).json({
error: 'Internal server error',
message: 'Unable to retrieve online player count'
});
}
});
module.exports = router;
// Cleanup on process exit
process.on('SIGINT', async () => {
try {
if (pool) {
await pool.close();
}
process.exit(0);
} catch (error) {
logger.error('Error closing connection pool:', error);
process.exit(1);
}
});
export default router;

18
src/servers/jpnApp.js Normal file
View file

@ -0,0 +1,18 @@
import express, { urlencoded } from 'express';
import config from '../config.js';
const { ports, ips, logger } = config;
import authJpnRouter from '../routes/authJpn.js';
import billingJpnRouter from '../routes/billingJpn.js';
const app = express();
app.use(urlencoded({ extended: false, type: 'application/x-www-form-urlencoded' }));
app.use('/Auth', authJpnRouter);
app.use('/Billing', billingJpnRouter);
const startServer = () => {
return app.listen(ports.jpnApp, ips.local, () => {
logger.info(`API (JPN) listening on ${ips.local}:${ports.jpnApp}`);
});
};
export { app, startServer };

85
src/servers/mainApp.js Normal file
View file

@ -0,0 +1,85 @@
import express from 'express';
import config from '../config.js';
const { ports, ips, apiConfig, middleware, staticPaths, logger } = config;
import rateLimiter from '../lib/rateLimiter.js';
import { closeConnection } from '../lib/closeConnection.js';
import path from 'path';
// Routers
import gatewayRouter from '../routes/gateway.js';
import loginRouter from '../routes/launcher/login.js';
import registerRouter from '../routes/launcher/registerAccount.js';
import codeVerificationRouter from '../routes/launcher/codeVerification.js';
import passwordResetEmailRouter from '../routes/launcher/passwordResetEmail.js';
import passwordChangeRouter from '../routes/launcher/changePassword.js';
import verificationEmailRouter from '../routes/launcher/verificationEmail.js';
import launcherUpdaterRouter from '../routes/launcher/launcherUpdater.js';
import onlineCountRouter from '../routes/onlineCount.js';
const app = express();
if (apiConfig.trustProxyEnabled) {
const trustProxyHosts = apiConfig.trustProxyHosts || [];
if (trustProxyHosts.length > 0) {
app.set("trust proxy", trustProxyHosts);
} else {
app.set("trust proxy", true);
}
}
app.disable("x-powered-by");
app.disable("etag");
// Middleware
app.use(...middleware.getMiddleware());
// Routes
app.use('/launcher/GetGatewayAction', closeConnection, rateLimiter, gatewayRouter);
app.use('/launcher/SignupAction', closeConnection, rateLimiter, registerRouter);
app.use('/launcher/LoginAction', closeConnection, rateLimiter, loginRouter);
app.use('/launcher/VerifyCodeAction', closeConnection, rateLimiter, codeVerificationRouter);
app.use('/launcher/ResetPasswordAction', closeConnection, rateLimiter, passwordChangeRouter);
app.use('/launcher/SendPasswordResetEmailAction', closeConnection, rateLimiter, passwordResetEmailRouter);
app.use('/launcher/SendVerificationEmailAction', closeConnection, rateLimiter, verificationEmailRouter);
app.use('/launcherAction', closeConnection, rateLimiter, launcherUpdaterRouter);
app.use('/launcher/GetOnlineCountAction', closeConnection, rateLimiter, onlineCountRouter);
// Static files
app.use(express.static(staticPaths.public));
app.use('/launcher/news', express.static(staticPaths.launcherNews));
app.use('/site', express.static(staticPaths.site));
app.use('/launcher/patch', express.static(staticPaths.launcherPatch));
app.use('/launcher/client', express.static(staticPaths.launcherClient));
// HTML routes
app.get('/launcher/news', (req, res) => {
res.sendFile(path.join(staticPaths.launcherNews, 'news-panel.html'));
});
app.get('/launcher/agreement', (req, res) => {
res.sendFile(path.join(staticPaths.launcherNews, 'agreement.html'));
});
app.get('/favicon.ico', (req, res) => {
res.sendFile(path.join(staticPaths.launcherNews, 'favicon.ico'));
});
app.get('/Register', (req, res) => {
res.sendFile(path.join(staticPaths.site, 'Signup.html'));
});
// Error handler
app.use((err, req, res, next) => {
logger.error(err.stack);
return res.status(500).json({
result: 'ServerError',
message: 'A server error occurred. Please try again later.'
});
});
const startServer = () => {
return app.listen(ports.main, ips.public, () => {
logger.info(`API listening on ${ips.public}:${ports.main}`);
});
};
export { app, startServer };

151
src/servers/proxyServer.js Normal file
View file

@ -0,0 +1,151 @@
import { createServer } from 'net';
import { request } from 'http';
import config from '../config.js';
const { ports, ips, logger, BACKENDS } = config;
const parseRequest = (data) => {
const lines = data.split('\r\n');
let realPath = '/';
let isMalformed = false;
if (lines.some(line => line.startsWith('POST /') && line.includes('HTTP/1.1Content-Type:'))) {
isMalformed = true;
for (const line of lines) {
if (line.startsWith('POST /') && line.includes('HTTP/1.1Content-Type:')) {
const match = line.match(/POST (\/[^ ]*) HTTP\/1\.1/);
if (match) realPath = match[1];
break;
}
}
} else {
const requestLine = lines[0];
const match = requestLine.match(/^(POST|GET) (\/(?:[^ ]*)) HTTP\/1\.1$/i);
if (match) realPath = match[2];
}
if (realPath.startsWith('/cgi-bin/')) {
realPath = '/Auth' + realPath;
} else if (realPath.startsWith('/S1/')) {
realPath = '/Billing' + realPath;
}
return { realPath, isMalformed, lines };
};
const findBackend = (realPath) => {
if (BACKENDS.AUTH.paths.includes(realPath)) {
return { ...BACKENDS.AUTH, port: ports.jpnApp };
} else if (BACKENDS.BILLING.paths.some(path => realPath.startsWith(path))) {
return { ...BACKENDS.BILLING, port: ports.jpnApp };
} else {
return null;
}
};
const buildHeaders = (lines, body, backend, isMalformed) => {
const headers = {
'Host': `${ips.local}:${backend.port}`,
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
'User-Agent': 'Mozilla/4.0 (ISAO/1.00;Auth)',
};
if (!isMalformed) {
const headerLines = lines.slice(1, lines.indexOf(''));
for (const line of headerLines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
if (['content-type', 'user-agent', 'content-length'].includes(key)) {
headers[key] = value;
}
}
}
}
return headers;
};
const handleProxyRequest = (socket, data) => {
try {
const { realPath, isMalformed, lines } = parseRequest(data);
const backend = findBackend(realPath);
if (!backend) {
logger.error('[Proxy] Unknown path:', realPath);
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
return;
}
const body = data.split('\r\n\r\n')[1] || '';
const headers = buildHeaders(lines, body, backend, isMalformed);
const options = {
hostname: ips.local,
port: backend.port,
path: realPath,
method: 'POST',
headers,
};
const proxyReq = request(options, (proxyRes) => {
socket.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n`);
Object.entries(proxyRes.headers).forEach(([key, value]) => {
socket.write(`${key}: ${value}\r\n`);
});
socket.write('\r\n');
proxyRes.pipe(socket);
});
proxyReq.on('error', (err) => {
logger.error('[Proxy] Proxy error:', err);
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
});
proxyReq.write(body);
proxyReq.end();
} catch (err) {
logger.error('[Proxy] Error parsing request:', err);
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
}
};
const createProxyServer = () => {
const server = createServer((socket) => {
let data = '';
socket.on('data', (chunk) => {
data += chunk.toString('binary');
if (data.includes('\r\n\r\n')) {
handleProxyRequest(socket, data);
}
});
socket.on('error', (err) => {
logger.error('[Proxy] Socket error:', err);
});
socket.on('timeout', () => {
logger.warn('[Proxy] Socket timeout.');
socket.end();
});
});
return server;
};
const startServer = () => {
const server = createProxyServer();
return server.listen(ports.proxy, ips.local, () => {
logger.info(`Proxy (JPN) listening on ${ips.local}:${ports.proxy}`);
console.log('Configured backends:');
console.log(`- AUTH (${ports.jpnApp}):`, BACKENDS.AUTH.paths);
console.log(`- BILLING (${ports.jpnApp}):`, BACKENDS.BILLING.paths);
});
};
export { createProxyServer, startServer };

17
src/servers/usaApp.js Normal file
View file

@ -0,0 +1,17 @@
import express from 'express';
import config from '../config.js';
const { ports, ips, logger } = config;
import authUsaRouter from '../routes/authUsa.js';
import billingRouter from '../routes/billingUsa.js';
const app = express();
app.use('/Auth', authUsaRouter);
app.use('/Billing', billingRouter);
const startServer = () => {
return app.listen(ports.usaApp, ips.local, () => {
logger.info(`API (USA) listening on ${ips.local}:${ports.usaApp}`);
});
};
export { app, startServer };

View file

@ -0,0 +1,334 @@
import sql from "mssql";
import {
generateMD5Hash,
comparePassword,
hashPassword,
} from "../utils/hashUtils.js";
import { connAccount } from "../utils/dbConfig.js";
import configs from "../config.js";
const { config } = configs;
import {
sendConfirmationEmail,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail,
} from "../mailer/mailer.js";
import { logger } from "../utils/logger.js";
// ==============================================
// Account Management Functions
// ==============================================
export async function getAccount(identifier) {
const pool = connAccount;
const result = await pool
.request()
.input("Identifier", sql.VarChar(50), identifier)
.execute("GetAccount");
const row = result.recordset[0];
if (!row || row.Result !== "AccountExists") {
throw new Error(row?.Result || "AccountNotFound");
}
return row;
}
export async function createAccount(account, email, password, ip, verificationCode) {
const isValidVerificationCode = await verifyCode(
email,
verificationCode,
"Account"
);
if (isValidVerificationCode !== "ValidVerificationCode") {
return isValidVerificationCode;
}
const md5Password = generateMD5Hash(account, password);
const passwordHash = await hashPassword(md5Password, 10);
const pool = connAccount;
const request = pool.request();
request.input("WindyCode", sql.VarChar, account);
request.input("AccountPwd", sql.VarChar, passwordHash);
request.input("Email", sql.VarChar, email);
request.input("RegisterIP", sql.VarChar, ip);
request.input("ServerId", sql.Int, config.serverId);
request.input("ShopBalance", sql.BigInt, config.shopBalance);
const result = await request.execute("CreateAccount");
const row = result.recordset[0];
if (row.Result == "AccountCreated") {
sendConfirmationEmail(email, account);
await clearVerificationCode(email);
}
return row.Result;
}
export async function validateCredentials(account, password) {
try {
const accountStatus = await getAccount(account);
const passwordHash = generateMD5Hash(account, password);
const passwordMatch = await comparePassword(
passwordHash,
accountStatus.AccountPwd
);
return accountStatus.Result == "AccountExists" && passwordMatch;
} catch (error) {
return false;
}
}
// ==============================================
// Authentication Functions
// ==============================================
export async function authenticateUser(account, password, ip, isMd5 = false) {
const pool = connAccount;
// Get account info
const { recordset } = await pool
.request()
.input("Identifier", sql.VarChar(50), account)
.execute("GetAccount");
const row = recordset[0];
if (!row || row.Result !== "AccountExists") {
return { status: row.Result };
}
// Verify password
const passwordHash = isMd5 ? password : generateMD5Hash(account, password);
const passwordMatch = await comparePassword(passwordHash, row.AccountPwd);
// Authenticate
const { recordset: authRecordset } = await pool
.request()
.input("Identifier", sql.VarChar(50), account)
.input("password_verify_result", sql.Bit, passwordMatch ? 1 : 0)
.input("LastLoginIP", sql.VarChar(50), ip)
.execute("AuthenticateUser");
const authRow = authRecordset[0];
if (!authRow || authRow.Result !== "LoginSuccess") {
return { status: authRow.Result };
}
return {
status: authRow.Result,
authId: authRow.AuthID,
token: authRow.Token,
};
}
// ==============================================
// Password Management Functions
// ==============================================
export async function changeAccountPassword(email, password, verificationCode) {
// Get account information
const accountInfo = await getAccount(email);
if (!accountInfo || accountInfo.Result !== "AccountExists") {
return accountInfo;
}
// Check verification code
const verificationResult = await verifyCode(
email,
verificationCode,
"Password"
);
if (!verificationResult || verificationResult !== "ValidVerificationCode") {
return verificationResult;
}
const accountName = accountInfo.WindyCode;
const currentHash = accountInfo.AccountPwd;
const passwordHash = generateMD5Hash(accountName, password);
// Verify if password is the same
const isSamePassword = await comparePassword(passwordHash, currentHash);
if (isSamePassword) {
return "SamePassword";
}
// Hash and update password
const newPasswordHash = await hashPassword(passwordHash);
const updateResult = await updatePassword(email, newPasswordHash);
if (!updateResult || updateResult.Result !== "PasswordChanged") {
return updateResult;
}
// Clear verification code and send email
await clearVerificationCode(email);
sendPasswordChangedEmail(email, accountName);
return updateResult.Result;
}
export async function updatePassword(email, newPasswordHash) {
const pool = connAccount;
const result = await pool
.request()
.input("Email", sql.VarChar, email)
.input("AccountPwd", sql.VarChar, newPasswordHash)
.execute("UpdateAccountPassword");
return result.recordset[0];
}
// ==============================================
// Verification Code Functions
// ==============================================
export function generateVerificationCode() {
const verificationCode = Math.floor(10000 + Math.random() * 90000).toString();
// Set the expiration time 10 minutes from now in the specified timezone
const expirationTime = new Date(Date.now() + 600000).toLocaleString(
"en-US",
config.timeZone
);
return {
code: verificationCode,
expiration: expirationTime,
};
}
export async function sendAccountVerificationEmail(email) {
const verificationCode = generateVerificationCode();
const insertRow = await setAccountVerificationCode(
email,
verificationCode.code,
verificationCode.expiration
);
if (insertRow.Result == "Success") {
// Send verification code email
sendVerificationEmail(email, verificationCode.code);
return "EmailSent";
} else {
return insertRow.Result;
}
}
export async function sendPasswordVerificationEmail(email) {
const verificationCode = generateVerificationCode();
const insertRow = await setPasswordVerificationCode(
email,
verificationCode.code,
verificationCode.expiration
);
if (insertRow.Result == "Success") {
// Send verification code email
sendPasswordResetEmail(email, verificationCode.code);
return "EmailSent";
} else {
return insertRow.Result;
}
}
export async function setAccountVerificationCode(
email,
verificationCode,
expirationTime
) {
const pool = connAccount;
const result = await pool
.request()
.input("Email", sql.VarChar, email)
.input("VerificationCode", sql.VarChar, verificationCode)
.input("ExpirationTime", sql.DateTime, expirationTime)
.execute("SetAccountVerificationCode");
const row = result.recordset[0];
return row;
}
export async function setPasswordVerificationCode(
email,
verificationCode,
expirationTime
) {
const pool = connAccount;
const result = await pool
.request()
.input("Email", sql.VarChar, email)
.input("VerificationCode", sql.VarChar, verificationCode)
.input("ExpirationTime", sql.DateTime, expirationTime)
.execute("SetPasswordVerificationCode");
return result.recordset[0];
}
export async function verifyCode(email, verificationCode, verificationCodeType) {
const pool = connAccount;
const result = await pool
.request()
.input("Email", sql.VarChar, email)
.input("VerificationCode", sql.VarChar, verificationCode)
.input("VerificationCodeType", sql.VarChar, verificationCodeType)
.execute("GetVerificationCode");
const row = result.recordset[0];
return row.Result;
}
export async function clearVerificationCode(email) {
const pool = connAccount;
await pool
.request()
.input("Email", sql.VarChar, email)
.execute("ClearVerificationCode");
}
// ==============================================
// Billing Functions
// ==============================================
export async function getCurrency(userId) {
const pool = connAccount;
const result = await pool
.request()
.input("UserId", sql.VarChar(50), userId)
.input("ServerId", sql.Int, config.serverId)
.execute("GetCurrency");
const row = result.recordset[0];
if (!row || row.Result !== "Success") {
throw new Error(row?.Result || "Failed to get balance");
}
return row.Zen;
}
export async function setCurrency(userId, newBalance) {
const pool = connAccount;
const result = await pool
.request()
.input("UserId", sql.VarChar(50), userId)
.input("ServerId", sql.Int, config.serverId)
.input("NewBalance", sql.BigInt, newBalance)
.execute("SetCurrency");
if (result.rowsAffected[0] === 0) {
throw new Error("Balance update failed");
}
}
export async function logBillingTransaction(transaction) {
const pool = connAccount;
await pool
.request()
.input("userid", sql.VarChar(50), transaction.userId)
.input("charid", sql.VarChar(50), transaction.charId)
.input("uniqueid", sql.VarChar(50), transaction.uniqueId)
.input("amount", sql.BigInt, transaction.amount)
.input("itemid", sql.VarChar(50), transaction.itemId)
.input("itemcount", sql.Int, transaction.count)
.execute("SetBillingLog");
}

View file

@ -0,0 +1,20 @@
import sql from "mssql";
import configs from "../config.js";
const { config } = configs;
import { logger } from "../utils/logger.js";
// ==============================================
// Fetch online count from the database
// ==============================================
export async function fetchOnlineCount(pool) {
try {
const QUERY = 'SELECT COUNT(*) AS OnlineCount FROM AuthTable WHERE online = @online';
const request = pool.request();
request.input('online', sql.Int, 1);
const result = await request.query(QUERY);
return result.recordset[0].OnlineCount;
} catch (error) {
logger.error('Online count query failed:', error);
throw error;
}
}

View file

@ -1,6 +1,5 @@
const sql = require('mssql');
const env = require('./env');
const { logger } = require('../utils/logger');
import sql from 'mssql';
import { logger } from '../utils/logger.js';
const dbConfig = {
user: process.env.DB_USER,
@ -32,7 +31,7 @@ const authDBConfig = {
},
};
module.exports = {
export {
connAccount,
authDBConfig
};

View file

@ -1,6 +0,0 @@
require('dotenv').config();
// Access environment variables with a function
module.exports = {
get: (key) => process.env[key],
};

36
src/utils/getClientIp.js Normal file
View file

@ -0,0 +1,36 @@
import config from "../config.js";
const { apiConfig } = config;
export function getClientIp(req) {
let ip;
if (apiConfig.trustProxyEnabled) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
// Grab the first IP from x-forwarded-for list
ip = forwarded.split(",")[0].trim();
}
}
// Fallback to connection-based IP
if (!ip) {
ip = req.socket?.remoteAddress || req.connection?.remoteAddress || null;
}
// Optional fallback (only if explicitly included in body)
if (!ip && req.body?.ip) {
ip = req.body.ip;
}
// Normalize IPv6 localhost
if (ip === "::1" || ip === "0:0:0:0:0:0:0:1") {
ip = "127.0.0.1";
}
// Remove IPv6 prefix if needed (e.g. "::ffff:192.168.0.1")
if (ip && ip.startsWith("::ffff:")) {
ip = ip.substring(7);
}
return ip;
}

12
src/utils/hashUtils.js Normal file
View file

@ -0,0 +1,12 @@
import { createHash } from 'crypto';
import bcrypt from 'bcryptjs';
export function generateMD5Hash(account, password) {
return createHash('md5').update(account + password).digest('hex');
}
export async function hashPassword(password, saltRounds = 10) {
return await bcrypt.hash(password, saltRounds);
}
export async function comparePassword(password, hash) {
return await bcrypt.compare(password, hash);
}

View file

@ -1,15 +1,17 @@
const fs = require("fs");
const winston = require("winston");
import dotenv from 'dotenv';
dotenv.config();
import { existsSync, mkdirSync } from "fs";
import { transports as _transports, format as _format, createLogger as _createLogger } from "winston";
const logsDirectory = 'logs';
if (!fs.existsSync(logsDirectory)) {
fs.mkdirSync(logsDirectory);
if (!existsSync(logsDirectory)) {
mkdirSync(logsDirectory);
}
function createLogger(filename, level, filter, showConsole) {
const transports = [
new winston.transports.File({
new _transports.File({
filename: `${logsDirectory}/${filename}-${new Date().toISOString().slice(0, 10)}.log`,
level,
filter
@ -17,20 +19,20 @@ function createLogger(filename, level, filter, showConsole) {
];
if (showConsole) {
transports.push(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
winston.format.printf(info => `${info.level}: ${info.message} [${info.timestamp}]`)
transports.push(new _transports.Console({
format: _format.combine(
_format.colorize(),
_format.simple(),
_format.printf(info => `${info.level}: ${info.message} [${info.timestamp}]`)
)
}));
}
const logger = winston.createLogger({
const logger = _createLogger({
level,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(info => `${info.level}: ${info.message} [${info.timestamp}] `)
format: _format.combine(
_format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
_format.printf(info => `${info.level}: ${info.message} [${info.timestamp}] `)
),
transports
});
@ -46,7 +48,7 @@ const mailerLogger = createLogger('mailer', logLevel, log => log.message.include
const accountLogger = createLogger('account', logLevel, log => log.message.includes('[Account]'), process.env.LOG_ACCOUNT_CONSOLE === 'true');
const logger = createLogger('api', logLevel, null, true);
module.exports = {
export {
authLogger,
billingLogger,
mailerLogger,

31
src/utils/memoryLogger.js Normal file
View file

@ -0,0 +1,31 @@
import moment from 'moment-timezone';
import { formatBytes } from './systemInfo.js';
function setupMemoryLogging(interval = 1800000) { // 30 minutes
// Set up periodic logging
const intervalId = setInterval(logMemoryUsage, interval);
// Return function to stop logging
return () => {
clearInterval(intervalId);
console.log('Stopped memory logging');
};
}
function logMemoryUsage() {
const now = moment().format('YYYY-MM-DD HH:mm:ss');
const mem = process.memoryUsage();
console.log(`Memory Usage at ${now}:`);
console.log(` RSS : ${formatBytes(mem.rss)}`);
console.log(` Heap Total : ${formatBytes(mem.heapTotal)}`);
console.log(` Heap Used : ${formatBytes(mem.heapUsed)}`);
console.log(` External : ${formatBytes(mem.external)}`);
console.log(` Array Buffers: ${formatBytes(mem.arrayBuffers)}`);
console.log('--------------------------------------------------');
}
export default {
setupMemoryLogging,
logMemoryUsage
};

61
src/utils/systemInfo.js Normal file
View file

@ -0,0 +1,61 @@
import tz from 'moment-timezone';
import fs from 'fs/promises';
const data = await fs.readFile(new URL('../../package.json', import.meta.url), 'utf-8');
const { version: APP_VERSION } = JSON.parse(data);
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
function getSystemInfo() {
const nodeVersion = process.version;
const timezone = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
const offsetInMinutes = tz.tz(timezone).utcOffset();
const offsetSign = offsetInMinutes >= 0 ? '+' : '-';
const offsetHours = Math.floor(Math.abs(offsetInMinutes) / 60).toString().padStart(2, '0');
const offsetMinutes = (Math.abs(offsetInMinutes) % 60).toString().padStart(2, '0');
const offsetString = `${offsetSign}${offsetHours}:${offsetMinutes}`;
const memoryUsage = process.memoryUsage();
const githubLink = 'https://github.com/JuniorDark/RustyHearts-API';
return {
version: `Rusty Hearts API Version: ${APP_VERSION}`,
github: `Github: ${githubLink}`,
nodeVersion: `Node.js Version: ${nodeVersion}`,
timezone: `Timezone: ${timezone} (${offsetString})`,
memory: {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external),
arrayBuffers: formatBytes(memoryUsage.arrayBuffers)
}
};
}
function logSystemInfo() {
const info = getSystemInfo();
console.log('--------------------------------------------------');
console.log(info.version);
console.log(info.github);
console.log(info.nodeVersion);
console.log(info.timezone);
console.log('Memory Usage:');
console.log(` RSS : ${info.memory.rss}`);
console.log(` Heap Total : ${info.memory.heapTotal}`);
console.log(` Heap Used : ${info.memory.heapUsed}`);
console.log(` External : ${info.memory.external}`);
console.log(` Array Buffers: ${info.memory.arrayBuffers}`);
console.log('--------------------------------------------------');
}
export {
getSystemInfo,
logSystemInfo,
formatBytes
};

4
start-All.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API
node src/app.js mainApp usaApp jpnApp proxyServer
pause

4
start-JPN.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API (Jpn)
node src/app.js mainApp jpnApp proxyServer
pause

4
start-USA.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API (Usa)
node src/app.js mainApp usaApp
pause

4
start_with_pm2_All.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API
cmd /k "npx pm2 start ecosystem.config.cjs --only rh-api-all && npx pm2 logs rh-api"
pause

4
start_with_pm2_JPN.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API (Japan Server)
cmd /k "npx pm2 start ecosystem.config.cjs --only rh-api-jpn && npx pm2 logs rh-api"
pause

4
start_with_pm2_USA.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title Rusty Hearts API (Usa Server)
cmd /k "npx pm2 start ecosystem.config.cjs --only rh-api-usa && npx pm2 logs rh-api"
pause

View file

@ -1,4 +0,0 @@
@echo off
title API
cmd /k "npx pm2 stop rh-api"
pause

4
stop_with_pm2.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
title API
cmd /k "npx pm2 stop all"
pause