mirror of
https://github.com/kuroppoi/entralinked.git
synced 2026-03-21 17:24:40 -05:00
Commit source
This commit is contained in:
parent
43c9e84d69
commit
7ce0211e25
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#
|
||||
# https://help.github.com/articles/dealing-with-line-endings/
|
||||
#
|
||||
# These are explicitly windows files and should use crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
19
.github/workflows/dist-pull-request.yml
vendored
Normal file
19
.github/workflows/dist-pull-request.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
name: Build on Pull Request
|
||||
on: pull_request
|
||||
jobs:
|
||||
dist:
|
||||
runs-on: ubuntu-lastest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: temurin
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
- name: Run Gradle dist
|
||||
run: ./gradlew dist
|
||||
25
.github/workflows/dist-upload-artifact.yml
vendored
Normal file
25
.github/workflows/dist-upload-artifact.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Build and Upload Artifact
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
dist:
|
||||
runs-on: ubuntu-lastest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: temurin
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
- name: Run Gradle dist
|
||||
run: ./gradlew dist
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: entralinked
|
||||
path: build/libs/entralinked.jar
|
||||
retention-days: 7
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Gradle
|
||||
.gradle
|
||||
build
|
||||
run
|
||||
testRun
|
||||
|
||||
# Eclipse
|
||||
.metadata
|
||||
.settings
|
||||
.project
|
||||
.classpath
|
||||
bin
|
||||
29
README.md
29
README.md
|
|
@ -1 +1,28 @@
|
|||
# entralinked
|
||||
# Entralinked
|
||||
[](https://github.com/kuroppoi/entralinked/actions)
|
||||
|
||||
Entralinked is a standalone Game Sync emulator developed for use with Pokémon Black & White and Pokémon Black 2 & White 2.\
|
||||
Its purpose is to serve as a simple utility for downloading Pokémon, Items, C-Gear skins, Pokédex skins and Musicals\
|
||||
without needing to edit your save file.
|
||||
|
||||
## Building
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Java 17 Development Kit
|
||||
|
||||
```
|
||||
git clone --recurse-submodules https://github.com/kuroppoi/entralinked.git
|
||||
cd entralinked
|
||||
./gradlew dist
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Execute `entralinked.jar`, or without the user interface:
|
||||
```
|
||||
java -jar entralinked.jar disablegui
|
||||
```
|
||||
Entralinked has a built-in DNS server. In order for your game to connect, you must configure the DNS settings of your DS.\
|
||||
By default, Entralinked is configured to use the local host of the system.\
|
||||
After tucking in a Pokémon, navigate to `http://localhost/dashboard/profile.html` in a web browser to configure Game Sync settings.
|
||||
|
|
|
|||
86
build.gradle
Normal file
86
build.gradle
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
project.ext {
|
||||
mainClass = 'entralinked.Entralinked'
|
||||
agentClass = 'entralinked.LauncherAgent'
|
||||
workingDirectory = 'run'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations {
|
||||
signedImplementation
|
||||
implementation.extendsFrom signedImplementation
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
|
||||
signedImplementation 'org.bouncycastle:bcpkix-jdk15on:1.70'
|
||||
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
|
||||
implementation 'org.apache.logging.log4j:log4j-api:2.20.0'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2'
|
||||
implementation 'io.netty:netty-all:4.1.79.Final'
|
||||
implementation 'io.javalin:javalin:5.5.0'
|
||||
implementation 'org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources {
|
||||
srcDir 'poke-sprites-v'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
workingDir = "testRun";
|
||||
useJUnitPlatform()
|
||||
|
||||
doFirst {
|
||||
mkdir workingDir
|
||||
}
|
||||
}
|
||||
|
||||
compileJava {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
task dist(type: Jar) {
|
||||
manifest {
|
||||
attributes 'Main-Class': project.ext.mainClass,
|
||||
'Launcher-Agent-Class': project.ext.agentClass,
|
||||
'Multi-Release': 'true'
|
||||
}
|
||||
|
||||
from {
|
||||
(configurations.runtimeClasspath - configurations.signedImplementation).collect {
|
||||
it.isDirectory() ? it : zipTree(it)
|
||||
}
|
||||
}
|
||||
|
||||
from {
|
||||
configurations.signedImplementation
|
||||
}
|
||||
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
dependsOn configurations.runtimeClasspath, test
|
||||
with jar
|
||||
}
|
||||
|
||||
task run(type: JavaExec) {
|
||||
mainClass = project.ext.mainClass
|
||||
workingDir = project.ext.workingDirectory
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
|
||||
doFirst {
|
||||
mkdir workingDir
|
||||
}
|
||||
}
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
|
|
@ -0,0 +1 @@
|
|||
org.gradle.logging.level=info
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
240
gradlew
vendored
Normal file
240
gradlew
vendored
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
91
gradlew.bat
vendored
Normal file
91
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
3
scripts/gradlew-clean.bat
Normal file
3
scripts/gradlew-clean.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
call ../gradlew clean -p .. --stacktrace
|
||||
pause
|
||||
3
scripts/gradlew-dist.bat
Normal file
3
scripts/gradlew-dist.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
call ../gradlew dist -p .. --stacktrace
|
||||
pause
|
||||
3
scripts/gradlew-test.bat
Normal file
3
scripts/gradlew-test.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
call ../gradlew test -p .. --stacktrace
|
||||
pause
|
||||
1
settings.gradle
Normal file
1
settings.gradle
Normal file
|
|
@ -0,0 +1 @@
|
|||
rootProject.name = 'entralinked'
|
||||
15
src/main/java/entralinked/CommandLineArguments.java
Normal file
15
src/main/java/entralinked/CommandLineArguments.java
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package entralinked;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public record CommandLineArguments(boolean disableGui) {
|
||||
|
||||
public CommandLineArguments(Collection<String> args) {
|
||||
this(args.contains("disablegui"));
|
||||
}
|
||||
|
||||
public CommandLineArguments(String... args) {
|
||||
this(List.of(args));
|
||||
}
|
||||
}
|
||||
14
src/main/java/entralinked/Configuration.java
Normal file
14
src/main/java/entralinked/Configuration.java
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package entralinked;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Configuration(
|
||||
@JsonProperty(required = true) String hostName,
|
||||
@JsonProperty(required = true) boolean clearPlayerDreamInfoOnWake,
|
||||
@JsonProperty(required = true) boolean allowOverwritingPlayerDreamInfo,
|
||||
@JsonProperty(required = true) boolean allowWfcRegistrationThroughLogin) {
|
||||
|
||||
public static final Configuration DEFAULT = new Configuration("local", true, false, true);
|
||||
}
|
||||
156
src/main/java/entralinked/Entralinked.java
Normal file
156
src/main/java/entralinked/Entralinked.java
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package entralinked;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import entralinked.gui.MainView;
|
||||
import entralinked.model.dlc.DlcList;
|
||||
import entralinked.model.player.PlayerManager;
|
||||
import entralinked.model.user.UserManager;
|
||||
import entralinked.network.dns.DnsServer;
|
||||
import entralinked.network.gamespy.GameSpyServer;
|
||||
import entralinked.network.http.HttpServer;
|
||||
import entralinked.network.http.dashboard.DashboardHandler;
|
||||
import entralinked.network.http.dls.DlsHandler;
|
||||
import entralinked.network.http.nas.NasHandler;
|
||||
import entralinked.network.http.pgl.PglHandler;
|
||||
|
||||
public class Entralinked {
|
||||
|
||||
public static void main(String[] args) {
|
||||
new Entralinked(args);
|
||||
}
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
private final Configuration configuration;
|
||||
private final DlcList dlcList;
|
||||
private final UserManager userManager;
|
||||
private final PlayerManager playerManager;
|
||||
private final DnsServer dnsServer;
|
||||
private final GameSpyServer gameSpyServer;
|
||||
private final HttpServer httpServer;
|
||||
|
||||
public Entralinked(String[] args) {
|
||||
// Read command line arguments
|
||||
CommandLineArguments arguments = new CommandLineArguments(args);
|
||||
|
||||
// Create GUI if enabled
|
||||
if(!arguments.disableGui()) {
|
||||
try {
|
||||
SwingUtilities.invokeAndWait(() -> new MainView(this));
|
||||
} catch (InvocationTargetException | InterruptedException e) {
|
||||
logger.error("An error occured whilst creating main view", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load config
|
||||
configuration = loadConfigFile();
|
||||
logger.info("Using configuration {}", configuration);
|
||||
|
||||
// Get host address
|
||||
InetAddress hostAddress = null;
|
||||
String hostName = configuration.hostName();
|
||||
|
||||
if(hostName.equals("local") || hostName.equals("localhost")) {
|
||||
hostAddress = getLocalHost();
|
||||
} else {
|
||||
try {
|
||||
hostAddress = InetAddress.getByName(hostName);
|
||||
} catch(UnknownHostException e) {
|
||||
hostAddress = getLocalHost();
|
||||
logger.error("Could not resolve host name - falling back to {} ", hostAddress, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load persistent data
|
||||
dlcList = new DlcList();
|
||||
userManager = new UserManager();
|
||||
playerManager = new PlayerManager();
|
||||
|
||||
// Create DNS server
|
||||
dnsServer = new DnsServer(hostAddress);
|
||||
dnsServer.start();
|
||||
|
||||
// Create GameSpy server
|
||||
gameSpyServer = new GameSpyServer(this);
|
||||
gameSpyServer.start();
|
||||
|
||||
// Create HTTP server
|
||||
httpServer = new HttpServer(this);
|
||||
httpServer.addHandler(new NasHandler(this));
|
||||
httpServer.addHandler(new PglHandler(this));
|
||||
httpServer.addHandler(new DlsHandler(this));
|
||||
httpServer.addHandler(new DashboardHandler(this));
|
||||
httpServer.start();
|
||||
}
|
||||
|
||||
public void stopServers() {
|
||||
if(httpServer != null) {
|
||||
httpServer.stop();
|
||||
}
|
||||
|
||||
if(gameSpyServer != null) {
|
||||
gameSpyServer.stop();
|
||||
}
|
||||
|
||||
if(dnsServer != null) {
|
||||
dnsServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private Configuration loadConfigFile() {
|
||||
logger.info("Loading configuration ...");
|
||||
|
||||
try {
|
||||
File configFile = new File("config.json");
|
||||
|
||||
if(!configFile.exists()) {
|
||||
logger.info("No configuration file exists - default configuration will be used");
|
||||
mapper.writeValue(configFile, Configuration.DEFAULT);
|
||||
return Configuration.DEFAULT;
|
||||
} else {
|
||||
return mapper.readValue(configFile, Configuration.class);
|
||||
}
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not load configuration - default configuration will be used", e);
|
||||
return Configuration.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
private InetAddress getLocalHost() {
|
||||
try {
|
||||
return InetAddress.getLocalHost();
|
||||
} catch(UnknownHostException e) {
|
||||
logger.error("Could not resolve local host", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Configuration getConfiguration() {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public DlcList getDlcList() {
|
||||
return dlcList;
|
||||
}
|
||||
|
||||
public UserManager getUserManager() {
|
||||
return userManager;
|
||||
}
|
||||
|
||||
public PlayerManager getPlayerManager() {
|
||||
return playerManager;
|
||||
}
|
||||
}
|
||||
104
src/main/java/entralinked/GameVersion.java
Normal file
104
src/main/java/entralinked/GameVersion.java
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package entralinked;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public enum GameVersion {
|
||||
|
||||
// ==================================
|
||||
// Black Version & White Version
|
||||
// ==================================
|
||||
|
||||
BLACK_JAPANESE(21, 1, "IRBJ", "ブラック"),
|
||||
BLACK_ENGLISH(21, 2, "IRBO", "Black Version"),
|
||||
BLACK_FRENCH(21, 3, "IRBF", "Version Noire"),
|
||||
BLACK_ITALIAN(21, 4, "IRBI", "Versione Nera"),
|
||||
BLACK_GERMAN(21, 5, "IRBD", "Schwarze Edition"),
|
||||
BLACK_SPANISH(21, 7, "IRBS", "Edicion Negra"),
|
||||
BLACK_KOREAN(21, 8, "IRBK", "블랙"),
|
||||
|
||||
WHITE_JAPANESE(20, 1, "IRAJ", "ホワイト"),
|
||||
WHITE_ENGLISH(20, 2, "IRAO", "White Version"),
|
||||
WHITE_FRENCH(20, 3, "IRAF", "Version Blanche"),
|
||||
WHITE_ITALIAN(20, 4, "IRAI", "Versione Bianca"),
|
||||
WHITE_GERMAN(20, 5, "IRAD", "Weisse Edition"),
|
||||
WHITE_SPANISH(20, 7, "IRAS", "Edicion Blanca"),
|
||||
WHITE_KOREAN(20, 8, "IRAK", "화이트"),
|
||||
|
||||
// ==================================
|
||||
// Black Version 2 & White Version 2
|
||||
// ==================================
|
||||
|
||||
BLACK_2_JAPANESE(23, 1, "IREJ", "ブラック2", true),
|
||||
BLACK_2_ENGLISH(23, 2, "IREO", "Black Version 2", true),
|
||||
BLACK_2_FRENCH(23, 3, "IREF", "Version Noire 2", true),
|
||||
BLACK_2_ITALIAN(23, 4, "IREI", "Versione Nera 2", true),
|
||||
BLACK_2_GERMAN(23, 5, "IRED", "Schwarze Edition 2", true),
|
||||
BLACK_2_SPANISH(23, 7, "IRES", "Edicion Negra 2", true),
|
||||
BLACK_2_KOREAN(23, 8, "IREK", "블랙2", true),
|
||||
|
||||
WHITE_2_JAPANESE(22, 1, "IRDJ", "ホワイト2", true),
|
||||
WHITE_2_ENGLISH(22, 2, "IRDO", "White Version 2", true),
|
||||
WHITE_2_FRENCH(22, 3, "IRDF", "Version Blanche 2", true),
|
||||
WHITE_2_ITALIAN(22, 4, "IRDI", "Versione Bianca 2", true),
|
||||
WHITE_2_GERMAN(22, 5, "IRDD", "Weisse Edition 2", true),
|
||||
WHITE_2_SPANISH(22, 7, "IRDS", "Edicion Blanca 2", true),
|
||||
WHITE_2_KOREAN(22, 8, "IRDK", "화이트2", true);
|
||||
|
||||
// Lookup maps
|
||||
private static final Map<String, GameVersion> mapBySerial = new HashMap<>();
|
||||
private static final Map<Integer, GameVersion> mapByCodes = new HashMap<>();
|
||||
|
||||
static {
|
||||
for(GameVersion version : values()) {
|
||||
mapBySerial.put(version.getSerial(), version);
|
||||
mapByCodes.put(version.getRomCode() << version.getLanguageCode(), version);
|
||||
}
|
||||
}
|
||||
|
||||
private final int romCode;
|
||||
private final int languageCode; // Values are not tested
|
||||
private final String serial;
|
||||
private final String displayName;
|
||||
private final boolean isVersion2;
|
||||
|
||||
private GameVersion(int romCode, int languageCode, String serial, String displayName, boolean isVersion2) {
|
||||
this.romCode = romCode;
|
||||
this.languageCode = languageCode;
|
||||
this.serial = serial;
|
||||
this.displayName = displayName;
|
||||
this.isVersion2 = isVersion2;
|
||||
}
|
||||
|
||||
private GameVersion(int romCode, int languageCode, String serial, String displayName) {
|
||||
this(romCode, languageCode, serial, displayName, false);
|
||||
}
|
||||
|
||||
public static GameVersion lookup(String serial) {
|
||||
return mapBySerial.get(serial);
|
||||
}
|
||||
|
||||
public static GameVersion lookup(int romCode, int languageCode) {
|
||||
return mapByCodes.get(romCode << languageCode);
|
||||
}
|
||||
|
||||
public int getRomCode() {
|
||||
return romCode;
|
||||
}
|
||||
|
||||
public int getLanguageCode() {
|
||||
return languageCode;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public boolean isVersion2() {
|
||||
return isVersion2;
|
||||
}
|
||||
}
|
||||
47
src/main/java/entralinked/LauncherAgent.java
Normal file
47
src/main/java/entralinked/LauncherAgent.java
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package entralinked;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.instrument.Instrumentation;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Stupid solution to a stupid problem.
|
||||
* If this just randomly breaks in the future because of some nonsense security reason I will completely lose it.
|
||||
*/
|
||||
public class LauncherAgent {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static boolean bouncyCastlePresent = true;
|
||||
|
||||
public static void agentmain(String args, Instrumentation instrumentation) {
|
||||
try {
|
||||
String[] jarNames = {
|
||||
"bcutil-jdk15on-1.70.jar",
|
||||
"bcprov-jdk15on-1.70.jar",
|
||||
"bcpkix-jdk15on-1.70.jar"
|
||||
};
|
||||
|
||||
for(int i = 0; i < jarNames.length; i++) {
|
||||
String jarName = jarNames[i];
|
||||
Path jarPath = Files.createTempFile(jarNames[i], null);
|
||||
File jarFile = jarPath.toFile();
|
||||
jarFile.deleteOnExit(); // Doesn't actually do anything on terminal exit because Java.
|
||||
Files.copy(LauncherAgent.class.getResourceAsStream("/%s".formatted(jarName)), jarPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
instrumentation.appendToSystemClassLoaderSearch(new JarFile(jarFile));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.error("Could not add BouncyCastle to SystemClassLoader search", e);
|
||||
bouncyCastlePresent = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isBouncyCastlePresent() {
|
||||
return bouncyCastlePresent;
|
||||
}
|
||||
}
|
||||
125
src/main/java/entralinked/gui/MainView.java
Normal file
125
src/main/java/entralinked/gui/MainView.java
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package entralinked.gui;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Desktop;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.UnsupportedLookAndFeelException;
|
||||
import javax.swing.text.AttributeSet;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.DefaultCaret;
|
||||
import javax.swing.text.Document;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
import javax.swing.text.StyleContext;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.utility.ConsumerAppender;
|
||||
|
||||
/**
|
||||
* Simple Swing user interface.
|
||||
*/
|
||||
public class MainView {
|
||||
|
||||
private static Logger logger = LogManager.getLogger();
|
||||
private final StyleContext styleContext = StyleContext.getDefaultStyleContext();
|
||||
private final AttributeSet fontAttribute = styleContext.addAttribute(SimpleAttributeSet.EMPTY, StyleConstants.FontFamily, "Consolas");
|
||||
|
||||
public MainView(Entralinked entralinked) {
|
||||
// Try set Look and Feel
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (ReflectiveOperationException | UnsupportedLookAndFeelException e) {
|
||||
logger.error("Could not set Look and Feel", e);
|
||||
}
|
||||
|
||||
// Create dashboard button
|
||||
JButton dashboardButton = new JButton("Open User Dashboard");
|
||||
dashboardButton.setFocusable(false);
|
||||
dashboardButton.addActionListener(event -> {
|
||||
openUrl("http://127.0.0.1/dashboard/profile.html");
|
||||
});
|
||||
|
||||
// Create console output
|
||||
JTextPane consoleOutputPane = new JTextPane() {
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return getPreferredSize().width <= getParent().getSize().width;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
return getUI().getPreferredSize(this);
|
||||
};
|
||||
};
|
||||
consoleOutputPane.setFont(new Font("Consola", Font.PLAIN, 12));
|
||||
consoleOutputPane.setEditable(false);
|
||||
((DefaultCaret)consoleOutputPane.getCaret()).setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);
|
||||
|
||||
// Create console output appender
|
||||
ConsumerAppender.addConsumer("GuiOutput", message -> {
|
||||
Document document = consoleOutputPane.getDocument();
|
||||
try {
|
||||
consoleOutputPane.getDocument().insertString(document.getLength(), message, fontAttribute);
|
||||
} catch(BadLocationException e) {}
|
||||
});
|
||||
|
||||
// Create console output scroll pane
|
||||
JScrollPane scrollPane = new JScrollPane();
|
||||
scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
|
||||
scrollPane.setViewportView(consoleOutputPane);
|
||||
|
||||
// Create main panel
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.add(scrollPane, BorderLayout.CENTER);
|
||||
panel.add(dashboardButton, BorderLayout.PAGE_END);
|
||||
|
||||
// Create window
|
||||
JFrame frame = new JFrame("Entralinked");
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(WindowEvent event) {
|
||||
// Run asynchronously so it doesn't just awkwardly freeze
|
||||
// Still scuffed but better than nothing I guess
|
||||
CompletableFuture.runAsync(() -> {
|
||||
entralinked.stopServers();
|
||||
System.exit(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
|
||||
frame.setMinimumSize(new Dimension(512, 288));
|
||||
frame.add(panel);
|
||||
frame.pack();
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setVisible(true);
|
||||
}
|
||||
|
||||
private void openUrl(String url) {
|
||||
Desktop desktop = Desktop.getDesktop();
|
||||
|
||||
if(desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
try {
|
||||
desktop.browse(new URL(url).toURI());
|
||||
} catch(IOException | URISyntaxException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/main/java/entralinked/model/dlc/Dlc.java
Normal file
7
src/main/java/entralinked/model/dlc/Dlc.java
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package entralinked.model.dlc;
|
||||
|
||||
/**
|
||||
* Simple record for DLC data.
|
||||
*/
|
||||
public record Dlc(String path, String name, String gameCode, String type,
|
||||
int index, int projectedSize, int checksum, boolean checksumEmbedded) {}
|
||||
185
src/main/java/entralinked/model/dlc/DlcList.java
Normal file
185
src/main/java/entralinked/model/dlc/DlcList.java
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package entralinked.model.dlc;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import entralinked.utility.Crc16;
|
||||
|
||||
public class DlcList {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final Map<String, Dlc> dlcMap = new ConcurrentHashMap<>();
|
||||
private final File dataDirectory = new File("dlc");
|
||||
|
||||
public DlcList() {
|
||||
logger.info("Loading DLC ...");
|
||||
|
||||
// Extract defaults if external DLC directory is not present
|
||||
if(!dataDirectory.exists()) {
|
||||
logger.info("Extracting default DLC files ...");
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(getClass().getResourceAsStream("/dlc.paths")));
|
||||
String line = null;
|
||||
|
||||
try {
|
||||
while((line = reader.readLine()) != null) {
|
||||
InputStream resource = getClass().getResourceAsStream(line);
|
||||
File outputFile = new File("./%s".formatted(line));
|
||||
|
||||
// Create parent directories
|
||||
if(outputFile.getParentFile() != null) {
|
||||
outputFile.getParentFile().mkdirs();
|
||||
}
|
||||
System.out.println(outputFile);
|
||||
// Copy resource to destination
|
||||
if(resource != null) {
|
||||
Files.copy(resource, outputFile.toPath());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error("Could not extract default DLC files", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Just to be sure...
|
||||
if(!dataDirectory.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Game Serial level
|
||||
for(File file : dataDirectory.listFiles()) {
|
||||
// Make sure that file is a directory, log warning and skip otherwise
|
||||
if(!file.isDirectory()) {
|
||||
logger.warn("Non-directory '{}' in DLC root folder", file.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
// DLC Type level
|
||||
for(File subFile : file.listFiles()) {
|
||||
// Check if file is directory
|
||||
if(!subFile.isDirectory()) {
|
||||
logger.warn("Non-directory '{}' in DLC subfolder '{}'", file.getName(), subFile.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = 1;
|
||||
|
||||
// DLC Content level
|
||||
for(File dlcFile : subFile.listFiles()) {
|
||||
// Load DLC data
|
||||
Dlc dlc = loadDlcFile(file.getName(), subFile.getName(), index, dlcFile);
|
||||
|
||||
// Index DLC object if loading succeeded
|
||||
if(dlc != null) {
|
||||
dlcMap.put(dlc.name(), dlc);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Loaded {} DLC file(s)", dlcMap.size());
|
||||
}
|
||||
|
||||
private Dlc loadDlcFile(String gameCode, String type, int index, File dlcFile) {
|
||||
String name = dlcFile.getName();
|
||||
|
||||
if(dlcMap.containsKey(name)) {
|
||||
logger.warn("Duplicate DLC name {}", name);
|
||||
return null;
|
||||
}
|
||||
|
||||
if(dlcFile.isDirectory()) {
|
||||
logger.warn("Directory '{}' in {} DLC folder", name, gameCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there is a valid CRC-16 checksum appended at the end of the file.
|
||||
// If not, it will be marked in the DLC record object and the server will automatically append the checksum
|
||||
// when the DLC content is requested.
|
||||
// Makes it easier to just throw stuff into the DLC folder.
|
||||
int projectedSize = 0;
|
||||
int checksum = 0;
|
||||
boolean checksumEmbedded = true;
|
||||
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(dlcFile.toPath());
|
||||
projectedSize = bytes.length;
|
||||
checksum = Crc16.calc(bytes, 0, bytes.length - 2);
|
||||
int checksumInFile = (bytes[bytes.length - 2] & 0xFF) | ((bytes[bytes.length - 1] & 0xFF) << 8);
|
||||
|
||||
if(checksum != checksumInFile) {
|
||||
projectedSize += 2;
|
||||
checksum = Crc16.calc(bytes, 0, bytes.length);
|
||||
checksumEmbedded = false;
|
||||
}
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not read checksum data for {}", dlcFile.getAbsolutePath(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Dlc(dlcFile.getAbsolutePath(), name, gameCode, type, index, projectedSize, checksum, checksumEmbedded);
|
||||
}
|
||||
|
||||
public List<Dlc> getDlcList(Predicate<Dlc> filter) {
|
||||
return getDlc().stream().filter(filter).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<Dlc> getDlcList(String gameCode, String type, int index) {
|
||||
return getDlcList(dlc ->
|
||||
dlc.gameCode().equals(gameCode) &&
|
||||
dlc.type().equals(type) &&
|
||||
dlc.index() == index
|
||||
);
|
||||
}
|
||||
|
||||
public List<Dlc> getDlcList(String gameCode, String type) {
|
||||
return getDlcList(dlc ->
|
||||
dlc.gameCode().equals(gameCode) &&
|
||||
dlc.type().equals(type)
|
||||
);
|
||||
}
|
||||
|
||||
public List<Dlc> getDlcList(String gameCode) {
|
||||
return getDlcList(dlc -> dlc.gameCode().equals(gameCode));
|
||||
}
|
||||
|
||||
public String getDlcListString(Collection<Dlc> dlcList) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
dlcList.forEach(dlc -> {
|
||||
builder.append("%s\t\t%s\t%s\t\t%s\r\n".formatted(dlc.name(), dlc.type(), dlc.index(), dlc.projectedSize()));
|
||||
});
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public Dlc getDlc(String name) {
|
||||
return dlcMap.get(name);
|
||||
}
|
||||
|
||||
public int getDlcIndex(String name) {
|
||||
return dlcExists(name) ? getDlc(name).index() : 0;
|
||||
}
|
||||
|
||||
public boolean dlcExists(String name) {
|
||||
return name != null && dlcMap.containsKey(name);
|
||||
}
|
||||
|
||||
public Collection<Dlc> getDlc() {
|
||||
return Collections.unmodifiableCollection(dlcMap.values());
|
||||
}
|
||||
}
|
||||
11
src/main/java/entralinked/model/pkmn/PkmnGender.java
Normal file
11
src/main/java/entralinked/model/pkmn/PkmnGender.java
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package entralinked.model.pkmn;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
|
||||
|
||||
public enum PkmnGender {
|
||||
|
||||
@JsonEnumDefaultValue
|
||||
MALE,
|
||||
FEMALE,
|
||||
GENDERLESS;
|
||||
}
|
||||
28
src/main/java/entralinked/model/pkmn/PkmnInfo.java
Normal file
28
src/main/java/entralinked/model/pkmn/PkmnInfo.java
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package entralinked.model.pkmn;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Record containing information about a Pokémon
|
||||
*/
|
||||
public record PkmnInfo(
|
||||
@JsonProperty(required = true) int personality,
|
||||
@JsonProperty(required = true) int species,
|
||||
@JsonProperty(required = true) int heldItem,
|
||||
@JsonProperty(required = true) int trainerId,
|
||||
@JsonProperty(required = true) int trainerSecretId,
|
||||
@JsonProperty(required = true) int level,
|
||||
@JsonProperty(required = true) int form,
|
||||
@JsonProperty(required = true) PkmnNature nature,
|
||||
@JsonProperty(required = true) PkmnGender gender,
|
||||
@JsonProperty(required = true) String nickname,
|
||||
@JsonProperty(required = true) String trainerName) {
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isShiny() {
|
||||
int p1 = (personality >> 16) & 0xFFFF;
|
||||
int p2 = personality & 0xFFFF;
|
||||
return (trainerId ^ trainerSecretId ^ p1 ^ p2) < 8;
|
||||
}
|
||||
}
|
||||
123
src/main/java/entralinked/model/pkmn/PkmnInfoReader.java
Normal file
123
src/main/java/entralinked/model/pkmn/PkmnInfoReader.java
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package entralinked.model.pkmn;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
import io.netty.buffer.PooledByteBufAllocator;
|
||||
|
||||
/**
|
||||
* Utility class for reading binary Pokémon data.
|
||||
*/
|
||||
public class PkmnInfoReader {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static final ByteBufAllocator bufferAllocator = PooledByteBufAllocator.DEFAULT;
|
||||
private static final byte[] blockShuffleTable = {
|
||||
0, 1, 2, 3, 0, 1, 3, 2, 0, 2, 1, 3, 0, 3, 1, 2,
|
||||
0, 2, 3, 1, 0, 3, 2, 1, 1, 0, 2, 3, 1, 0, 3, 2,
|
||||
2, 0, 1, 3, 3, 0, 1, 2, 2, 0, 3, 1, 3, 0, 2, 1,
|
||||
1, 2, 0, 3, 1, 3, 0, 2, 2, 1, 0, 3, 3, 1, 0, 2,
|
||||
2, 3, 0, 1, 3, 2, 0, 1, 1, 2, 3, 0, 1, 3, 2, 0,
|
||||
2, 1, 3, 0, 3, 1, 2, 0, 2, 3, 1, 0, 3, 2, 1, 0
|
||||
};
|
||||
|
||||
public static PkmnInfo readPokeInfo(InputStream inputStream) throws IOException {
|
||||
ByteBuf buffer = bufferAllocator.buffer(236); // Allocate buffer
|
||||
buffer.writeBytes(inputStream, 236); // Read data from input stream into buffer
|
||||
|
||||
// Read header info
|
||||
int personality = buffer.readIntLE();
|
||||
buffer.skipBytes(2);
|
||||
int checksum = buffer.readShortLE() & 0x0000FFFF;
|
||||
|
||||
// Decrypt data
|
||||
decryptData(buffer, 8, 128, checksum);
|
||||
decryptData(buffer, 136, 100, personality);
|
||||
|
||||
// Unshuffle blocks
|
||||
ByteBuf shuffleBuffer = bufferAllocator.buffer(128); // Allocate shuffle buffer
|
||||
int shift = ((personality & 0x3E000) >> 0xD) % 24;
|
||||
|
||||
for(int i = 0; i < 4; i++) {
|
||||
int fromIndex = blockShuffleTable[i + shift * 4] * 32;
|
||||
int toIndex = i * 32;
|
||||
shuffleBuffer.setBytes(toIndex, buffer, 8 + fromIndex, 32);
|
||||
}
|
||||
|
||||
buffer.setBytes(8, shuffleBuffer, 0, 128);
|
||||
|
||||
// Try release shuffle buffer
|
||||
if(!shuffleBuffer.release()) {
|
||||
logger.warn("Buffer was not deallocated!");
|
||||
}
|
||||
|
||||
// Read Pokémon data
|
||||
int species = buffer.getShortLE(8);
|
||||
int item = buffer.getShortLE(10) & 0xFFFF;
|
||||
int trainerId = buffer.getShortLE(12) & 0xFFFF;
|
||||
int trainerSecretId = buffer.getShortLE(14) & 0xFFFF;
|
||||
int level = buffer.getByte(140);
|
||||
int ability = buffer.getByte(21) & 0xFF;
|
||||
int form = (buffer.getByte(64) >> 3) & 0xFF;
|
||||
boolean genderless = ((buffer.getByte(64) >> 2) & 1) == 1;
|
||||
boolean female = ((buffer.getByte(64) >> 1) & 1) == 1;
|
||||
PkmnGender gender = genderless ? PkmnGender.GENDERLESS : female ? PkmnGender.FEMALE : PkmnGender.MALE;
|
||||
PkmnNature nature = PkmnNature.valueOf(buffer.getByte(65));
|
||||
String nickname = getString(buffer, 72, 20);
|
||||
String trainerName = getString(buffer, 104, 14);
|
||||
|
||||
// Try release buffer
|
||||
if(!buffer.release()) {
|
||||
logger.warn("Buffer was not deallocated!");
|
||||
}
|
||||
|
||||
// Loosely verify data
|
||||
if(species < 1 || species > 649) throw new IOException("Invalid species");
|
||||
if(item < 0 || item > 638) throw new IOException("Invalid held item");
|
||||
if(ability < 1 || ability > 164) throw new IOException("Invalid ability");
|
||||
if(level < 1 || level > 100) throw new IOException("Level is out of range");
|
||||
if(nature == null) throw new IOException("Invalid nature");
|
||||
|
||||
// Create record
|
||||
PkmnInfo info = new PkmnInfo(personality, species, item, trainerId, trainerSecretId, level, form, nature, gender, nickname, trainerName);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static void decryptData(ByteBuf buffer, int offset, int length, int seed) throws IOException {
|
||||
if(length % 2 != 0) {
|
||||
throw new IOException("Length must be multiple of 2");
|
||||
}
|
||||
|
||||
int tempSeed = seed;
|
||||
|
||||
for(int i = 0; i < length / 2; i++) {
|
||||
int index = offset + i * 2;
|
||||
short word = buffer.getShortLE(index);
|
||||
tempSeed = 0x41C64E6D * tempSeed + 0x6073;
|
||||
buffer.setShortLE(index, (short)(word ^ (tempSeed >> 16)));
|
||||
}
|
||||
}
|
||||
|
||||
private static String getString(ByteBuf buffer, int offset, int length) {
|
||||
char[] charBuffer = new char[length];
|
||||
int read = 0;
|
||||
|
||||
for(int i = 0; i < charBuffer.length; i++) {
|
||||
int c = buffer.getShortLE(offset + i * 2) & 0xFFFF;
|
||||
|
||||
if(c == 0 || c == 65535) {
|
||||
break; // Doubt 65535 is a legitimate character..
|
||||
}
|
||||
|
||||
charBuffer[i] = (char)c;
|
||||
read++;
|
||||
}
|
||||
|
||||
return new String(charBuffer, 0, read);
|
||||
}
|
||||
}
|
||||
37
src/main/java/entralinked/model/pkmn/PkmnNature.java
Normal file
37
src/main/java/entralinked/model/pkmn/PkmnNature.java
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package entralinked.model.pkmn;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
|
||||
|
||||
public enum PkmnNature {
|
||||
|
||||
@JsonEnumDefaultValue
|
||||
HARDY,
|
||||
LONELY,
|
||||
BRAVE,
|
||||
ADAMANT,
|
||||
NAUGHTY,
|
||||
BOLD,
|
||||
DOCILE,
|
||||
RELAXED,
|
||||
IMPISH,
|
||||
LAX,
|
||||
TIMID,
|
||||
HASTY,
|
||||
SERIOUS,
|
||||
JOLLY,
|
||||
NAIVE,
|
||||
MODEST,
|
||||
MILD,
|
||||
QUIET,
|
||||
BASHFUL,
|
||||
RASH,
|
||||
CALM,
|
||||
GENTLE,
|
||||
SASSY,
|
||||
CAREFUL,
|
||||
QUIRKY;
|
||||
|
||||
public static PkmnNature valueOf(int index) {
|
||||
return index >= 0 && index < values().length ? values()[index] : null;
|
||||
}
|
||||
}
|
||||
51
src/main/java/entralinked/model/player/DreamAnimation.java
Normal file
51
src/main/java/entralinked/model/player/DreamAnimation.java
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
|
||||
|
||||
public enum DreamAnimation {
|
||||
|
||||
/**
|
||||
* Look around, but stay in the same position.
|
||||
*/
|
||||
@JsonEnumDefaultValue
|
||||
LOOK_AROUND,
|
||||
|
||||
/**
|
||||
* Walk around, but never change direction without moving a step in that direction.
|
||||
*/
|
||||
WALK_AROUND,
|
||||
|
||||
/**
|
||||
* Walk around and occasionally change direction without moving.
|
||||
*/
|
||||
WALK_LOOK_AROUND,
|
||||
|
||||
/**
|
||||
* Only walk up and down.
|
||||
*/
|
||||
WALK_VERTICALLY,
|
||||
|
||||
/**
|
||||
* Only walk left and right.
|
||||
*/
|
||||
WALK_HORIZONTALLY,
|
||||
|
||||
/**
|
||||
* Only walk left and right, and occasionally change direction without moving.
|
||||
*/
|
||||
WALK_LOOK_HORIZONTALLY,
|
||||
|
||||
/**
|
||||
* Continuously spin right.
|
||||
*/
|
||||
SPIN_RIGHT,
|
||||
|
||||
/**
|
||||
* Continuously spin left.
|
||||
*/
|
||||
SPIN_LEFT;
|
||||
|
||||
public static DreamAnimation valueOf(int index) {
|
||||
return index >= 0 && index < values().length ? values()[index] : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record DreamEncounter(
|
||||
@JsonProperty(required = true) int species,
|
||||
@JsonProperty(required = true) int move,
|
||||
@JsonProperty(required = true) int form,
|
||||
@JsonProperty(required = true) DreamAnimation animation) {}
|
||||
7
src/main/java/entralinked/model/player/DreamItem.java
Normal file
7
src/main/java/entralinked/model/player/DreamItem.java
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record DreamItem(
|
||||
@JsonProperty(required = true) int id,
|
||||
@JsonProperty(required = true) int quantity) {}
|
||||
117
src/main/java/entralinked/model/player/Player.java
Normal file
117
src/main/java/entralinked/model/player/Player.java
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import entralinked.GameVersion;
|
||||
import entralinked.model.pkmn.PkmnInfo;
|
||||
|
||||
public class Player {
|
||||
|
||||
private final String gameSyncId;
|
||||
private final GameVersion gameVersion;
|
||||
private final List<DreamEncounter> encounters = new ArrayList<>();
|
||||
private final List<DreamItem> items = new ArrayList<>();
|
||||
private PlayerStatus status;
|
||||
private PkmnInfo dreamerInfo;
|
||||
private int levelsGained;
|
||||
private String cgearSkin;
|
||||
private String dexSkin;
|
||||
private String musical;
|
||||
|
||||
public Player(String gameSyncId, GameVersion gameVersion) {
|
||||
this.gameSyncId = gameSyncId;
|
||||
this.gameVersion = gameVersion;
|
||||
}
|
||||
|
||||
public void resetDreamInfo() {
|
||||
status = PlayerStatus.AWAKE;
|
||||
dreamerInfo = null;
|
||||
encounters.clear();
|
||||
items.clear();
|
||||
levelsGained = 0;
|
||||
cgearSkin = null;
|
||||
dexSkin = null;
|
||||
musical = null;
|
||||
}
|
||||
|
||||
public String getGameSyncId() {
|
||||
return gameSyncId;
|
||||
}
|
||||
|
||||
public GameVersion getGameVersion() {
|
||||
return gameVersion;
|
||||
}
|
||||
|
||||
public void setEncounters(Collection<DreamEncounter> encounters) {
|
||||
if(encounters.size() <= 10) {
|
||||
this.encounters.clear();
|
||||
this.encounters.addAll(encounters);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DreamEncounter> getEncounters() {
|
||||
return Collections.unmodifiableList(encounters);
|
||||
}
|
||||
|
||||
public void setItems(Collection<DreamItem> items) {
|
||||
if(encounters.size() <= 20) {
|
||||
this.items.clear();
|
||||
this.items.addAll(items);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DreamItem> getItems() {
|
||||
return Collections.unmodifiableList(items);
|
||||
}
|
||||
|
||||
public void setStatus(PlayerStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public PlayerStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setDreamerInfo(PkmnInfo dreamerInfo) {
|
||||
this.dreamerInfo = dreamerInfo;
|
||||
}
|
||||
|
||||
public PkmnInfo getDreamerInfo() {
|
||||
return dreamerInfo;
|
||||
}
|
||||
|
||||
public void setLevelsGained(int levelsGained) {
|
||||
this.levelsGained = levelsGained;
|
||||
}
|
||||
|
||||
public int getLevelsGained() {
|
||||
return levelsGained;
|
||||
}
|
||||
|
||||
public void setCGearSkin(String cgearSkin) {
|
||||
this.cgearSkin = cgearSkin;
|
||||
}
|
||||
|
||||
public String getCGearSkin() {
|
||||
return cgearSkin;
|
||||
}
|
||||
|
||||
public void setDexSkin(String dexSkin) {
|
||||
this.dexSkin = dexSkin;
|
||||
}
|
||||
|
||||
public String getDexSkin() {
|
||||
return dexSkin;
|
||||
}
|
||||
|
||||
public void setMusical(String musical) {
|
||||
this.musical = musical;
|
||||
}
|
||||
|
||||
public String getMusical() {
|
||||
return musical;
|
||||
}
|
||||
}
|
||||
47
src/main/java/entralinked/model/player/PlayerDto.java
Normal file
47
src/main/java/entralinked/model/player/PlayerDto.java
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
import entralinked.GameVersion;
|
||||
import entralinked.model.pkmn.PkmnInfo;
|
||||
|
||||
/**
|
||||
* Serialization DTO for Global Link user information.
|
||||
*/
|
||||
public record PlayerDto(
|
||||
@JsonProperty(required = true) String gameSyncId,
|
||||
@JsonProperty(required = true) GameVersion gameVersion,
|
||||
PlayerStatus status,
|
||||
PkmnInfo dreamerInfo,
|
||||
String cgearSkin,
|
||||
String dexSkin,
|
||||
String musical,
|
||||
int levelsGained,
|
||||
@JsonDeserialize(contentAs = DreamEncounter.class) Collection<DreamEncounter> encounters,
|
||||
@JsonDeserialize(contentAs = DreamItem.class) Collection<DreamItem> items) {
|
||||
|
||||
public PlayerDto(Player player) {
|
||||
this(player.getGameSyncId(), player.getGameVersion(), player.getStatus(), player.getDreamerInfo(), player.getCGearSkin(),
|
||||
player.getDexSkin(), player.getMusical(), player.getLevelsGained(), player.getEncounters(), player.getItems());
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new {@link Player} object using the data in this DTO.
|
||||
*/
|
||||
public Player toPlayer() {
|
||||
Player player = new Player(gameSyncId, gameVersion);
|
||||
player.setStatus(status);
|
||||
player.setDreamerInfo(dreamerInfo);
|
||||
player.setCGearSkin(cgearSkin);
|
||||
player.setDexSkin(dexSkin);
|
||||
player.setMusical(musical);
|
||||
player.setLevelsGained(levelsGained);
|
||||
player.setEncounters(encounters == null ? Collections.emptyList() : encounters);
|
||||
player.setItems(items == null ? Collections.emptyList() : items);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
160
src/main/java/entralinked/model/player/PlayerManager.java
Normal file
160
src/main/java/entralinked/model/player/PlayerManager.java
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import entralinked.GameVersion;
|
||||
|
||||
/**
|
||||
* Manager class for managing {@link Player} information (Global Link users)
|
||||
*/
|
||||
public class PlayerManager {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
private final Map<String, Player> playerMap = new ConcurrentHashMap<>();
|
||||
private final File dataDirectory = new File("players");
|
||||
|
||||
public PlayerManager() {
|
||||
logger.info("Loading player data ...");
|
||||
|
||||
// Check if player directory exists
|
||||
if(!dataDirectory.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load player data
|
||||
for(File file : dataDirectory.listFiles()) {
|
||||
if(!file.isDirectory()) {
|
||||
loadPlayer(file);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Loaded {} player(s)", playerMap.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a {@link Player} from the specified input file.
|
||||
* The loaded player instance is automatically mapped, unless it has an already-mapped Game Sync ID.
|
||||
*/
|
||||
private void loadPlayer(File inputFile) {
|
||||
try {
|
||||
// Deserialize player data
|
||||
Player player = mapper.readValue(inputFile, PlayerDto.class).toPlayer();
|
||||
String gameSyncId = player.getGameSyncId();
|
||||
|
||||
// Check for duplicate Game Sync ID
|
||||
if(doesPlayerExist(gameSyncId)) {
|
||||
throw new IOException("Duplicate Game Sync ID %s".formatted(gameSyncId));
|
||||
}
|
||||
|
||||
playerMap.put(gameSyncId, player);
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not load player data at {}", inputFile.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves all player data.
|
||||
*/
|
||||
public void savePlayers() {
|
||||
playerMap.values().forEach(this::savePlayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the data of the specified player to disk, and returns {@code true} if it succeeds.
|
||||
* The output file is generated as follows:
|
||||
*
|
||||
* {@code new File(dataDirectory, "PGL-%s".formatted(gameSyncId))}
|
||||
*/
|
||||
public boolean savePlayer(Player player) {
|
||||
return savePlayer(player, new File(dataDirectory, "PGL-%s.json".formatted(player.getGameSyncId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the data of the specified player to the specified output file.
|
||||
*
|
||||
* @return {@code true} if the data was saved successfully, {@code false} otherwise.
|
||||
*/
|
||||
private boolean savePlayer(Player player, File outputFile) {
|
||||
try {
|
||||
// Create parent directories
|
||||
File parentFile = outputFile.getParentFile();
|
||||
|
||||
if(parentFile != null) {
|
||||
parentFile.mkdirs();
|
||||
}
|
||||
|
||||
// Serialize the entire player object first to minimize risk of corrupted files
|
||||
byte[] bytes = mapper.writeValueAsBytes(new PlayerDto(player));
|
||||
|
||||
// Write serialized data to output file
|
||||
Files.write(outputFile.toPath(), bytes);
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not save player data for {}", player.getGameSyncId(), e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to register a new {@link Player} with the given data.
|
||||
*
|
||||
* @return The newly created {@link Player} object if the registration succeeded.
|
||||
* That is, the specified Game Sync ID wasn't already registered and the player data
|
||||
* was saved without any errors.
|
||||
*/
|
||||
public Player registerPlayer(String gameSyncId, GameVersion gameVersion) {
|
||||
// Check for duplicate Game Sync ID
|
||||
if(playerMap.containsKey(gameSyncId)) {
|
||||
logger.warn("Attempted to register duplicate player {}", gameSyncId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Construct player object
|
||||
Player player = new Player(gameSyncId, gameVersion);
|
||||
player.setStatus(PlayerStatus.AWAKE);
|
||||
|
||||
// Try to save player data
|
||||
if(!savePlayer(player)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map player object & return it
|
||||
playerMap.put(gameSyncId, player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} if a player with the specified Game Sync ID exists, {@code false} otherwise.
|
||||
*/
|
||||
public boolean doesPlayerExist(String gameSyncId) {
|
||||
return playerMap.containsKey(gameSyncId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The {@link Player} object to which this Game Sync ID belongs, or {@code null} if no such player exists.
|
||||
*/
|
||||
public Player getPlayer(String gameSyncId) {
|
||||
return playerMap.get(gameSyncId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return An immutable {@link Collection} containing all players.
|
||||
*/
|
||||
public Collection<Player> getPlayers() {
|
||||
return Collections.unmodifiableCollection(playerMap.values());
|
||||
}
|
||||
}
|
||||
27
src/main/java/entralinked/model/player/PlayerStatus.java
Normal file
27
src/main/java/entralinked/model/player/PlayerStatus.java
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package entralinked.model.player;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
|
||||
|
||||
public enum PlayerStatus {
|
||||
|
||||
/**
|
||||
* No Pokémon is currently tucked in.
|
||||
*/
|
||||
@JsonEnumDefaultValue
|
||||
AWAKE,
|
||||
|
||||
/**
|
||||
* The tucked in Pokémon is asleep, but is not dreaming yet.
|
||||
*/
|
||||
SLEEPING,
|
||||
|
||||
/**
|
||||
* The tucked in Pokémon is dreaming - waking it up is not allowed.
|
||||
*/
|
||||
DREAMING,
|
||||
|
||||
/**
|
||||
* The dreamer is ready to be woken up.
|
||||
*/
|
||||
WAKE_READY,
|
||||
}
|
||||
58
src/main/java/entralinked/model/user/GameProfile.java
Normal file
58
src/main/java/entralinked/model/user/GameProfile.java
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
public class GameProfile {
|
||||
|
||||
private final int id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String aimName;
|
||||
private String zipCode;
|
||||
|
||||
public GameProfile(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public GameProfile(int id, String firstName, String lastName, String aimName, String zipCode) {
|
||||
this(id);
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.aimName = aimName;
|
||||
this.zipCode = zipCode;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setAimName(String aimName) {
|
||||
this.aimName = aimName;
|
||||
}
|
||||
|
||||
public String getAimName() {
|
||||
return aimName;
|
||||
}
|
||||
|
||||
public void setZipCode(String zipCode) {
|
||||
this.zipCode = zipCode;
|
||||
}
|
||||
|
||||
public String getZipCode() {
|
||||
return zipCode;
|
||||
}
|
||||
}
|
||||
19
src/main/java/entralinked/model/user/GameProfileDto.java
Normal file
19
src/main/java/entralinked/model/user/GameProfileDto.java
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record GameProfileDto(
|
||||
@JsonProperty(required = true) int id,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String aimName,
|
||||
String zipCode) {
|
||||
|
||||
public GameProfileDto(GameProfile profile) {
|
||||
this(profile.getId(), profile.getFirstName(), profile.getLastName(), profile.getAimName(), profile.getZipCode());
|
||||
}
|
||||
|
||||
public GameProfile toProfile() {
|
||||
return new GameProfile(id, firstName, lastName, aimName, zipCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
public record ServiceCredentials(String authToken, String challenge) {}
|
||||
15
src/main/java/entralinked/model/user/ServiceSession.java
Normal file
15
src/main/java/entralinked/model/user/ServiceSession.java
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.TemporalUnit;
|
||||
|
||||
public record ServiceSession(User user, String service, String branchCode, String challengeHash, LocalDateTime expiry) {
|
||||
|
||||
public ServiceSession(User user, String service, String branchCode, String challengeHash, long expiry, TemporalUnit expiryUnit) {
|
||||
this(user, service, branchCode, challengeHash, LocalDateTime.now().plus(expiry, expiryUnit));
|
||||
}
|
||||
|
||||
public boolean hasExpired() {
|
||||
return LocalDateTime.now().isAfter(expiry);
|
||||
}
|
||||
}
|
||||
50
src/main/java/entralinked/model/user/User.java
Normal file
50
src/main/java/entralinked/model/user/User.java
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class User {
|
||||
|
||||
private final String id;
|
||||
private final String password; // I debated hashing it, but.. it's a 3-digit password...
|
||||
private final Map<String, GameProfile> profiles = new HashMap<>();
|
||||
|
||||
public User(String id, String password) {
|
||||
this.id = id;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getFormattedId() {
|
||||
return "%s000".formatted(id).replaceAll("(.{4})(?!$)", "$1-");
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
protected void addProfile(String branchCode, GameProfile profile) {
|
||||
profiles.put(branchCode, profile);
|
||||
}
|
||||
|
||||
protected void removeProfile(String branchCode) {
|
||||
profiles.remove(branchCode);
|
||||
}
|
||||
|
||||
public GameProfile getProfile(String branchCode) {
|
||||
return profiles.get(branchCode);
|
||||
}
|
||||
|
||||
public Collection<GameProfile> getProfiles() {
|
||||
return Collections.unmodifiableCollection(profiles.values());
|
||||
}
|
||||
|
||||
protected Map<String, GameProfile> getProfileMap() {
|
||||
return Collections.unmodifiableMap(profiles);
|
||||
}
|
||||
}
|
||||
30
src/main/java/entralinked/model/user/UserDto.java
Normal file
30
src/main/java/entralinked/model/user/UserDto.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
public record UserDto(
|
||||
@JsonProperty(required = true) String id,
|
||||
@JsonProperty(required = true) String password,
|
||||
@JsonDeserialize(contentAs = GameProfileDto.class) Map<String, GameProfileDto> profiles) {
|
||||
|
||||
public UserDto(User user) {
|
||||
this(user.getId(), user.getPassword(),
|
||||
user.getProfileMap().entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Entry::getKey, entry -> new GameProfileDto(entry.getValue()))));
|
||||
}
|
||||
|
||||
public User toUser() {
|
||||
User user = new User(id, password);
|
||||
profiles.entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Entry::getKey, e -> e.getValue().toProfile())) // Map GameProfileDto to GameProfile
|
||||
.forEach(user::addProfile); // Add each GameProfile to the User object
|
||||
return user;
|
||||
}
|
||||
}
|
||||
284
src/main/java/entralinked/model/user/UserManager.java
Normal file
284
src/main/java/entralinked/model/user/UserManager.java
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package entralinked.model.user;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import entralinked.utility.CredentialGenerator;
|
||||
import entralinked.utility.MD5;
|
||||
|
||||
/**
|
||||
* Manager class for managing {@link User} information (Wi-Fi Connection users)
|
||||
*
|
||||
* TODO session management is a bit scuffed
|
||||
*/
|
||||
public class UserManager {
|
||||
|
||||
public static final Pattern USER_ID_PATTERN = Pattern.compile("[0-9]{13}");
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
private final Map<String, User> users = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, GameProfile> profiles = new ConcurrentHashMap<>();
|
||||
private final Map<String, ServiceSession> serviceSessions = new ConcurrentHashMap<>();
|
||||
private final File dataDirectory = new File("users");
|
||||
|
||||
public UserManager() {
|
||||
logger.info("Loading user and profile data ...");
|
||||
|
||||
// Check if directory exists
|
||||
if(!dataDirectory.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user data
|
||||
for(File file : dataDirectory.listFiles()) {
|
||||
if(!file.isDirectory()) {
|
||||
loadUser(file);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Loaded {} user(s) with a total of {} profile(s)", users.size(), profiles.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} if this is a valid user ID.
|
||||
* That is, it has a length of 13 and only contains digits.
|
||||
*/
|
||||
public static boolean isValidUserId(String id) {
|
||||
return USER_ID_PATTERN.matcher(id).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a {@link User} from the specified input file.
|
||||
* The user is indexed automatically, unless it has a duplicate ID or any duplicate profile ID.
|
||||
*/
|
||||
private void loadUser(File inputFile) {
|
||||
try {
|
||||
// Deserialize user data
|
||||
User user = mapper.readValue(inputFile, UserDto.class).toUser();
|
||||
String id = user.getId();
|
||||
|
||||
// Check for duplicate user ID
|
||||
if(users.containsKey(id)) {
|
||||
throw new IOException("Duplicate user ID %s".formatted(id));
|
||||
}
|
||||
|
||||
// Check for duplicate profile IDs before indexing anything
|
||||
Collection<GameProfile> userProfiles = user.getProfiles();
|
||||
|
||||
if(userProfiles.stream().map(GameProfile::getId).anyMatch(profiles::containsKey)) {
|
||||
throw new IOException("Duplicate profile ID in user %s".formatted(id));
|
||||
}
|
||||
|
||||
// Index user
|
||||
users.put(id, user);
|
||||
|
||||
// Index profiles
|
||||
for(GameProfile profile : userProfiles) {
|
||||
profiles.put(profile.getId(), profile);
|
||||
}
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not load user data at {}", inputFile.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the data of all users.
|
||||
*/
|
||||
public void saveUsers() {
|
||||
users.values().forEach(this::saveUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the data of the specified user to disk, and returns {@code true} if it succeeds.
|
||||
* The output file is generated as follows:
|
||||
*
|
||||
* {@code new File(dataDirectory, "WFC-%s".formatted(formattedId))}
|
||||
* where {@code formattedId} is the user ID formatted to {@code ####-####-####-#000}
|
||||
*/
|
||||
public boolean saveUser(User user) {
|
||||
return saveUser(user, new File(dataDirectory, "WFC-%s.json".formatted(user.getFormattedId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the data of the specified user to the specified output file.
|
||||
*
|
||||
* @return {@code true} if the data was saved successfully, {@code false} otherwise.
|
||||
*/
|
||||
private boolean saveUser(User user, File outputFile) {
|
||||
try {
|
||||
// Create parent directories
|
||||
File parentFile = outputFile.getParentFile();
|
||||
|
||||
if(parentFile != null) {
|
||||
parentFile.mkdirs();
|
||||
}
|
||||
|
||||
// Serialize the entire user object first to minimize risk of corrupted files
|
||||
byte[] bytes = mapper.writeValueAsBytes(new UserDto(user));
|
||||
|
||||
// Finally, write the data.
|
||||
Files.write(outputFile.toPath(), bytes);
|
||||
} catch(IOException e) {
|
||||
logger.error("Could not save user data for user {}", user.getId(), e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates credentials for the client to use when logging into a separate service.
|
||||
* The information to be used by the service to verify the client will be cached and may be retrieved using {@link #getServiceSession(token, service)}.
|
||||
*
|
||||
* @return A {@link ServiceCredentials} record containing the auth token and (optional) challenge to send to the client.
|
||||
*/
|
||||
public ServiceCredentials createServiceSession(User user, String service, String branchCode) {
|
||||
if(service == null) {
|
||||
throw new IllegalArgumentException("service cannot be null");
|
||||
}
|
||||
|
||||
// Create token
|
||||
String authToken = "NDS%s".formatted(CredentialGenerator.generateAuthToken(96));
|
||||
|
||||
if(serviceSessions.containsKey(authToken)) {
|
||||
return createServiceSession(user, service, branchCode); // Top 5 things that never happen
|
||||
}
|
||||
|
||||
// Create challenge
|
||||
String challenge = CredentialGenerator.generateChallenge(8);
|
||||
|
||||
// Create session object
|
||||
ServiceSession session = new ServiceSession(user, service, branchCode, MD5.digest(challenge), 30, ChronoUnit.MINUTES);
|
||||
serviceSessions.put(authToken, session);
|
||||
return new ServiceCredentials(authToken, challenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A {@link ServiceSession} matching the specified auth token and service,
|
||||
* or {@code null} if there is none or if the existing session expired.
|
||||
*/
|
||||
public ServiceSession getServiceSession(String authToken, String service) {
|
||||
ServiceSession session = serviceSessions.get(authToken);
|
||||
|
||||
// Check if session exists
|
||||
if(session == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session has expired
|
||||
if(session.hasExpired()) {
|
||||
serviceSessions.remove(authToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.service().equals(service) ? session : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a user with the given ID and password.
|
||||
|
||||
* @return {@code true} if the registration was successful.
|
||||
* Otherwise, if this user ID has already been registered, or if the user data could not be saved, {@code false} is returned instead.
|
||||
*/
|
||||
public boolean registerUser(String userId, String plainPassword) {
|
||||
// Check if user id already exists
|
||||
if(users.containsKey(userId)) {
|
||||
logger.warn("Attempted to register user with duplicate ID: {}", userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create user
|
||||
User user = new User(userId, plainPassword); // TODO hash
|
||||
|
||||
// Save user data and return null if it fails
|
||||
if(!saveUser(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
users.put(userId, user);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple method that returns a {@link User} object whose credentials match the given ones.
|
||||
* If no user exists with matching credentials, {@code null} is returned instead.
|
||||
*/
|
||||
public User authenticateUser(String userId, String password) {
|
||||
User user = users.get(userId);
|
||||
return user == null || !user.getPassword().equals(password) ? null : user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new profile for the specified user.
|
||||
*
|
||||
* @return The newly created profile, or {@code null} if profile creation failed.
|
||||
*/
|
||||
public GameProfile createProfileForUser(User user, String branchCode) {
|
||||
// Check for duplicate profile
|
||||
if(user.getProfile(branchCode) != null) {
|
||||
logger.warn("Attempted to create duplicate profile {} in user {}", branchCode, user.getId());
|
||||
return null;
|
||||
}
|
||||
|
||||
int profileId = nextProfileId();
|
||||
GameProfile profile = new GameProfile(profileId);
|
||||
user.addProfile(branchCode, profile);
|
||||
|
||||
// Try to save user data and return null if it fails
|
||||
if(!saveUser(user)) {
|
||||
user.removeProfile(branchCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
profiles.put(profileId, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A unique random 32-bit profile ID.
|
||||
*/
|
||||
private int nextProfileId() {
|
||||
int profileId = (int)(Math.random() * Integer.MAX_VALUE);
|
||||
|
||||
// I live for that microscopic chance of StackOverflowError
|
||||
if(profiles.containsKey(profileId)) {
|
||||
return nextProfileId();
|
||||
}
|
||||
|
||||
return profileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} if a user with the specified ID exists, otherwise {@code false}.
|
||||
*/
|
||||
public boolean doesUserExist(String id) {
|
||||
return users.containsKey(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The {@link User} object to which this ID belongs, or {@code null} if it doesn't exist.
|
||||
*/
|
||||
public User getUser(String id) {
|
||||
return users.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return An immutable {@link Collection} containing all users.
|
||||
*/
|
||||
public Collection<User> getUsers() {
|
||||
return Collections.unmodifiableCollection(users.values());
|
||||
}
|
||||
}
|
||||
76
src/main/java/entralinked/network/NettyServerBase.java
Normal file
76
src/main/java/entralinked/network/NettyServerBase.java
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package entralinked.network;
|
||||
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.EventLoopGroup;
|
||||
import io.netty.channel.epoll.Epoll;
|
||||
import io.netty.channel.epoll.EpollEventLoopGroup;
|
||||
import io.netty.channel.nio.NioEventLoopGroup;
|
||||
import io.netty.util.concurrent.DefaultThreadFactory;
|
||||
import io.netty.util.concurrent.Future;
|
||||
|
||||
public abstract class NettyServerBase {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
protected final ThreadFactory threadFactory;
|
||||
protected final EventLoopGroup eventLoopGroup;
|
||||
protected final String name;
|
||||
protected final int port;
|
||||
protected boolean started;
|
||||
|
||||
public NettyServerBase(String name, int port) {
|
||||
this.threadFactory = new DefaultThreadFactory(name);
|
||||
this.name = name;
|
||||
this.port = port;
|
||||
|
||||
if(Epoll.isAvailable()) {
|
||||
eventLoopGroup = new EpollEventLoopGroup(threadFactory);
|
||||
} else {
|
||||
eventLoopGroup = new NioEventLoopGroup(threadFactory);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract ChannelFuture bootstrap(int port);
|
||||
|
||||
public boolean start() {
|
||||
if(started) {
|
||||
logger.warn("start() was called while {} server was already running!", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("Staring {} server ...", name);
|
||||
ChannelFuture future = bootstrap(port);
|
||||
|
||||
if(!future.isSuccess()) {
|
||||
logger.error("Could not start {} server", name, future.cause());
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info("{} server listening @ port {}", name, port);
|
||||
started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean stop() {
|
||||
if(!started) {
|
||||
logger.warn("stop() was called while {} server wasn't running!", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("Stopping {} server ...", name);
|
||||
Future<?> future = eventLoopGroup.shutdownGracefully().awaitUninterruptibly();
|
||||
|
||||
if(!future.isSuccess()) {
|
||||
logger.info("Could not stop {} server", name, future.cause());
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info("{} server stopped", name);
|
||||
started = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
45
src/main/java/entralinked/network/dns/DnsQueryHandler.java
Normal file
45
src/main/java/entralinked/network/dns/DnsQueryHandler.java
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package entralinked.network.dns;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.handler.codec.dns.DatagramDnsQuery;
|
||||
import io.netty.handler.codec.dns.DatagramDnsResponse;
|
||||
import io.netty.handler.codec.dns.DefaultDnsQuestion;
|
||||
import io.netty.handler.codec.dns.DefaultDnsRawRecord;
|
||||
import io.netty.handler.codec.dns.DnsRecordType;
|
||||
import io.netty.handler.codec.dns.DnsSection;
|
||||
|
||||
public class DnsQueryHandler extends SimpleChannelInboundHandler<DatagramDnsQuery> {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final InetAddress hostAddress;
|
||||
|
||||
public DnsQueryHandler(InetAddress hostAddress) {
|
||||
this.hostAddress = hostAddress;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, DatagramDnsQuery query) throws Exception {
|
||||
DefaultDnsQuestion question = query.recordAt(DnsSection.QUESTION);
|
||||
DnsRecordType type = question.type();
|
||||
|
||||
// We only need type A (32 bit IPv4) for the DS
|
||||
if(type != DnsRecordType.A) {
|
||||
logger.warn("Unsupported record type in DNS question: {}", type);
|
||||
return;
|
||||
}
|
||||
|
||||
ByteBuf addressBuffer = Unpooled.wrappedBuffer(hostAddress.getAddress());
|
||||
DefaultDnsRawRecord answer = new DefaultDnsRawRecord(question.name(), DnsRecordType.A, 0, addressBuffer);
|
||||
DatagramDnsResponse response = new DatagramDnsResponse(query.recipient(), query.sender(), query.id());
|
||||
response.addRecord(DnsSection.ANSWER, answer);
|
||||
ctx.writeAndFlush(response);
|
||||
}
|
||||
}
|
||||
41
src/main/java/entralinked/network/dns/DnsServer.java
Normal file
41
src/main/java/entralinked/network/dns/DnsServer.java
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package entralinked.network.dns;
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import entralinked.network.NettyServerBase;
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.socket.nio.NioDatagramChannel;
|
||||
import io.netty.handler.codec.dns.DatagramDnsQueryDecoder;
|
||||
import io.netty.handler.codec.dns.DatagramDnsResponseEncoder;
|
||||
|
||||
public class DnsServer extends NettyServerBase {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private InetAddress hostAddress;
|
||||
|
||||
public DnsServer(InetAddress hostAddress) {
|
||||
super("DNS", 53);
|
||||
this.hostAddress = hostAddress;
|
||||
logger.info("DNS queries will be resolved to {}", hostAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelFuture bootstrap(int port) {
|
||||
return new Bootstrap()
|
||||
.group(eventLoopGroup)
|
||||
.channel(NioDatagramChannel.class)
|
||||
.handler(new ChannelInitializer<NioDatagramChannel>() {
|
||||
@Override
|
||||
protected void initChannel(NioDatagramChannel channel) throws Exception {
|
||||
channel.pipeline().addLast(new DatagramDnsQueryDecoder());
|
||||
channel.pipeline().addLast(new DatagramDnsResponseEncoder());
|
||||
channel.pipeline().addLast(new DnsQueryHandler(hostAddress));
|
||||
}
|
||||
}).bind(port).awaitUninterruptibly();
|
||||
}
|
||||
}
|
||||
208
src/main/java/entralinked/network/gamespy/GameSpyHandler.java
Normal file
208
src/main/java/entralinked/network/gamespy/GameSpyHandler.java
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package entralinked.network.gamespy;
|
||||
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.model.user.GameProfile;
|
||||
import entralinked.model.user.ServiceSession;
|
||||
import entralinked.model.user.User;
|
||||
import entralinked.model.user.UserManager;
|
||||
import entralinked.network.gamespy.message.GameSpyChallengeMessage;
|
||||
import entralinked.network.gamespy.message.GameSpyErrorMessage;
|
||||
import entralinked.network.gamespy.message.GameSpyLoginResponse;
|
||||
import entralinked.network.gamespy.message.GameSpyProfileResponse;
|
||||
import entralinked.network.gamespy.request.GameSpyLoginRequest;
|
||||
import entralinked.network.gamespy.request.GameSpyProfileRequest;
|
||||
import entralinked.network.gamespy.request.GameSpyProfileUpdateRequest;
|
||||
import entralinked.network.gamespy.request.GameSpyRequest;
|
||||
import entralinked.utility.CredentialGenerator;
|
||||
import entralinked.utility.MD5;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
|
||||
/**
|
||||
* GameSpy request handler.
|
||||
*/
|
||||
public class GameSpyHandler extends SimpleChannelInboundHandler<GameSpyRequest> {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final UserManager userManager;
|
||||
private Channel channel;
|
||||
private String serverChallenge;
|
||||
private int sessionKey = -1; // It's pointless
|
||||
private User user;
|
||||
private GameProfile profile;
|
||||
|
||||
public GameSpyHandler(Entralinked entralinked) {
|
||||
this.userManager = entralinked.getUserManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(ChannelHandlerContext ctx) {
|
||||
channel = ctx.channel();
|
||||
|
||||
// Generate random server challenge
|
||||
serverChallenge = CredentialGenerator.generateChallenge(10);
|
||||
|
||||
// Send challenge message
|
||||
sendMessage(new GameSpyChallengeMessage(serverChallenge, 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext ctx) {
|
||||
// Clear data
|
||||
serverChallenge = null;
|
||||
sessionKey = -1;
|
||||
user = null;
|
||||
profile = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, GameSpyRequest request) throws Exception {
|
||||
request.process(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
|
||||
if(cause instanceof ClosedChannelException) {
|
||||
return; // Ignore this stupid exception.
|
||||
}
|
||||
|
||||
logger.error("Exception caught in GameSpy handler", cause);
|
||||
|
||||
// Send error message and close channel afterwards.
|
||||
sendErrorMessage(0x100, "An internal error occured on the server.", true, 0).addListener(future -> close());
|
||||
}
|
||||
|
||||
public void handleLoginRequest(GameSpyLoginRequest request) {
|
||||
String authToken = request.partnerToken();
|
||||
String clientChallenge = request.challenge();
|
||||
|
||||
// Check if session exists
|
||||
ServiceSession session = userManager.getServiceSession(authToken, "gamespy");
|
||||
|
||||
if(session == null) {
|
||||
sendErrorMessage(0x200, "Invalid partner token.", request.sequenceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify client credential hash
|
||||
String partnerChallengeHash = session.challengeHash();
|
||||
String expectedResponse = createCredentialHash(partnerChallengeHash, authToken, clientChallenge, serverChallenge);
|
||||
|
||||
if(!expectedResponse.equals(request.response())) {
|
||||
sendErrorMessage(0x202, "Invalid response.", request.sequenceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch profile or create one if it doesn't exist
|
||||
user = session.user();
|
||||
profile = user.getProfile(session.branchCode());
|
||||
|
||||
if(profile == null) {
|
||||
profile = userManager.createProfileForUser(user, session.branchCode());
|
||||
|
||||
// Check if creation succeeded
|
||||
if(profile == null) {
|
||||
sendErrorMessage(0x203, "Profile creation failed due to an error.", request.sequenceId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare and send response
|
||||
sessionKey = secureRandom.nextInt(Integer.MAX_VALUE);
|
||||
String proof = createCredentialHash(partnerChallengeHash, authToken, serverChallenge, clientChallenge);
|
||||
sendMessage(new GameSpyLoginResponse(user.getId(), profile.getId(), proof, sessionKey, request.sequenceId()));
|
||||
}
|
||||
|
||||
public void handleProfileRequest(GameSpyProfileRequest request) {
|
||||
sendMessage(new GameSpyProfileResponse(profile, request.sequenceId()));
|
||||
}
|
||||
|
||||
public void handleUpdateProfileRequest(GameSpyProfileUpdateRequest request) {
|
||||
// Update profile info
|
||||
boolean profileChanged = setValue(request::firstName, profile::setFirstName, profile::getFirstName);
|
||||
profileChanged |= setValue(request::lastName, profile::setLastName, profile::getLastName);
|
||||
profileChanged |= setValue(request::aimName, profile::setAimName, profile::getAimName);
|
||||
profileChanged |= setValue(request::zipCode, profile::setZipCode, profile::getZipCode);
|
||||
|
||||
// Save user data if the profile was changed
|
||||
if(profileChanged) {
|
||||
userManager.saveUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value if it isn't {@code null} and returns {@code true} if the value is different from the current value.
|
||||
*
|
||||
* @param valueSupplier Supplies the value that needs to be set
|
||||
* @param valueConsumer Consumes the value from {@code valueSupplier} (a setter)
|
||||
* @param currentValueSupplier Supplies the current value to test against the new value
|
||||
*/
|
||||
private <T> boolean setValue(Supplier<T> valueSupplier, Consumer<T> valueConsumer, Supplier<T> currentValueSupplier) {
|
||||
T value = valueSupplier.get();
|
||||
|
||||
// Return false if value is null or is equal to the existing value
|
||||
if(value == null || value.equals(currentValueSupplier.get())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
valueConsumer.accept(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected String createCredentialHash(String passwordHash, String user, String inChallenge, String outChallenge) {
|
||||
return MD5.digest("%s%s%s%s%s%s".formatted(
|
||||
passwordHash,
|
||||
" ",
|
||||
user,
|
||||
inChallenge,
|
||||
outChallenge,
|
||||
passwordHash));
|
||||
}
|
||||
|
||||
public void destroySessionKey(int sessionKey) {
|
||||
if(validateSessionKey(sessionKey)) {
|
||||
sessionKey = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateSessionKey(int sessionKey) {
|
||||
return validateSessionKey(sessionKey, 0);
|
||||
}
|
||||
|
||||
public boolean validateSessionKey(int sessionKey, int sequenceId) {
|
||||
if(sessionKey < 0 || this.sessionKey != sessionKey) {
|
||||
sendErrorMessage(0x201, "Invalid session key.", sequenceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public ChannelFuture sendMessage(Object message) {
|
||||
return channel.writeAndFlush(message).addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE);
|
||||
}
|
||||
|
||||
public ChannelFuture sendErrorMessage(int errorCode, String errorMessage, boolean fatal, int sequenceId) {
|
||||
return sendMessage(new GameSpyErrorMessage(errorCode, errorMessage, fatal ? 1 : 0, sequenceId));
|
||||
}
|
||||
|
||||
public ChannelFuture sendErrorMessage(int errorCode, String errorMessage, int sequenceId) {
|
||||
return sendErrorMessage(errorCode, errorMessage, false, sequenceId);
|
||||
}
|
||||
|
||||
public ChannelFuture close() {
|
||||
return channel.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package entralinked.network.gamespy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import entralinked.network.gamespy.message.GameSpyMessage;
|
||||
import entralinked.serialization.GameSpyMessageFactory;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.MessageToByteEncoder;
|
||||
|
||||
public class GameSpyMessageEncoder extends MessageToByteEncoder<Object> {
|
||||
|
||||
protected final ObjectMapper mapper;
|
||||
|
||||
/**
|
||||
* Supplied {@link ObjectMapper} should be configured to use the {@link GameSpyMessageFactory}
|
||||
*/
|
||||
public GameSpyMessageEncoder(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) throws Exception {
|
||||
Class<?> type = in.getClass();
|
||||
GameSpyMessage messageInfo = type.getAnnotation(GameSpyMessage.class);
|
||||
|
||||
if(messageInfo == null) {
|
||||
throw new IOException("Outbound message type '%s' must have the GameSpyMessage annotation.".formatted(type.getName()));
|
||||
}
|
||||
|
||||
out.writeByte('\\');
|
||||
writeString(out, messageInfo.name());
|
||||
out.writeByte('\\');
|
||||
writeString(out, messageInfo.value());
|
||||
out.writeBytes(mapper.writeValueAsBytes(in));
|
||||
writeString(out, "\\final\\");
|
||||
}
|
||||
|
||||
private void writeString(ByteBuf out, String string) {
|
||||
out.writeCharSequence(string, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package entralinked.network.gamespy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import entralinked.network.gamespy.request.GameSpyRequest;
|
||||
import entralinked.serialization.GameSpyMessageFactory;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.MessageToMessageDecoder;
|
||||
|
||||
public class GameSpyRequestDecoder extends MessageToMessageDecoder<ByteBuf> {
|
||||
|
||||
protected final ObjectMapper mapper;
|
||||
protected final Map<String, Class<GameSpyRequest>> requestTypes;
|
||||
|
||||
/**
|
||||
* Supplied {@link ObjectMapper} should be configured to use the {@link GameSpyMessageFactory}
|
||||
*/
|
||||
public GameSpyRequestDecoder(ObjectMapper mapper, Map<String, Class<GameSpyRequest>> requestTypes) {
|
||||
this.mapper = mapper;
|
||||
this.requestTypes = requestTypes;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
|
||||
// Sanity check
|
||||
byte b = in.readByte();
|
||||
|
||||
if(b != '\\') {
|
||||
throw new IOException("Was expecting '\\', got '%s'.".formatted((char)b));
|
||||
}
|
||||
|
||||
// Get request type
|
||||
String typeName = parseString(in, false);
|
||||
Class<GameSpyRequest> requestType = requestTypes.get(typeName);
|
||||
|
||||
if(requestType == null) {
|
||||
throw new IOException("Invalid or unimplemented request type '%s'".formatted(typeName));
|
||||
}
|
||||
|
||||
// Parse request value (?) if any bytes are remaining
|
||||
if(in.readableBytes() > 0) {
|
||||
parseString(in, true);
|
||||
}
|
||||
|
||||
// If there are still bytes left, use ObjectMapper to parse and map them.
|
||||
// Otherwise, create empty instance using reflection.
|
||||
GameSpyRequest request = null;
|
||||
|
||||
if(in.readableBytes() > 0) {
|
||||
byte[] bytes = new byte[in.readableBytes() + 1];
|
||||
bytes[0] = '\\'; // Cuz it was read as a terminator earlier..
|
||||
in.readBytes(bytes, 1, bytes.length - 1);
|
||||
request = mapper.readValue(bytes, requestType);
|
||||
} else {
|
||||
request = requestType.getConstructor().newInstance();
|
||||
}
|
||||
|
||||
out.add(request);
|
||||
}
|
||||
|
||||
private String parseString(ByteBuf in, boolean allowEOI) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
byte b = 0;
|
||||
|
||||
while((b = in.readByte()) != '\\') {
|
||||
builder.append((char)b);
|
||||
|
||||
if(allowEOI && in.readableBytes() == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
73
src/main/java/entralinked/network/gamespy/GameSpyServer.java
Normal file
73
src/main/java/entralinked/network/gamespy/GameSpyServer.java
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package entralinked.network.gamespy;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
|
||||
import com.fasterxml.jackson.databind.introspect.AnnotatedClassResolver;
|
||||
import com.fasterxml.jackson.databind.jsontype.NamedType;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.network.NettyServerBase;
|
||||
import entralinked.network.gamespy.request.GameSpyRequest;
|
||||
import entralinked.serialization.GameSpyMessageFactory;
|
||||
import io.netty.bootstrap.ServerBootstrap;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFuture;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
||||
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
|
||||
import io.netty.util.concurrent.DefaultEventExecutor;
|
||||
import io.netty.util.concurrent.EventExecutorGroup;
|
||||
|
||||
public class GameSpyServer extends NettyServerBase {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static final ObjectMapper mapper = new ObjectMapper(new GameSpyMessageFactory());
|
||||
private static final Map<String, Class<GameSpyRequest>> requestTypes = new HashMap<>();
|
||||
private final EventExecutorGroup handlerGroup = new DefaultEventExecutor(threadFactory);
|
||||
private final Entralinked entralinked;
|
||||
|
||||
static {
|
||||
logger.info("Mapping GameSpy request types ...");
|
||||
DeserializationConfig config = mapper.getDeserializationConfig();
|
||||
AnnotatedClass annotated = AnnotatedClassResolver.resolveWithoutSuperTypes(config, GameSpyRequest.class);
|
||||
Collection<NamedType> types = mapper.getSubtypeResolver().collectAndResolveSubtypesByClass(config, annotated);
|
||||
|
||||
for(NamedType type : types) {
|
||||
if(type.hasName()) {
|
||||
requestTypes.put(type.getName(), (Class<GameSpyRequest>)type.getType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public GameSpyServer(Entralinked entralinked) {
|
||||
super("GameSpy", 29900);
|
||||
this.entralinked = entralinked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelFuture bootstrap(int port) {
|
||||
return new ServerBootstrap()
|
||||
.group(eventLoopGroup)
|
||||
.channel(NioServerSocketChannel.class)
|
||||
.childHandler(new ChannelInitializer<Channel>() {
|
||||
@Override
|
||||
protected void initChannel(Channel channel) throws Exception {
|
||||
ChannelPipeline pipeline = channel.pipeline();
|
||||
pipeline.addLast(new DelimiterBasedFrameDecoder(512, Unpooled.wrappedBuffer("\\final\\".getBytes())));
|
||||
pipeline.addLast(new GameSpyRequestDecoder(mapper, requestTypes));
|
||||
pipeline.addLast(new GameSpyMessageEncoder(mapper));
|
||||
pipeline.addLast(handlerGroup, new GameSpyHandler(entralinked));
|
||||
}
|
||||
}).bind(port).awaitUninterruptibly();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package entralinked.network.gamespy.message;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@GameSpyMessage(name = "lc", value = "1")
|
||||
public record GameSpyChallengeMessage(
|
||||
@JsonProperty("challenge") String challenge,
|
||||
@JsonProperty("id") int sequenceId) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package entralinked.network.gamespy.message;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@GameSpyMessage(name = "error")
|
||||
public record GameSpyErrorMessage(
|
||||
@JsonProperty("err") int errorCode,
|
||||
@JsonProperty("errmsg") String errorMessage,
|
||||
@JsonProperty("fatal") int fatal,
|
||||
@JsonProperty("id") int sequenceId) {}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package entralinked.network.gamespy.message;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@GameSpyMessage(name = "lc", value = "2")
|
||||
public record GameSpyLoginResponse(
|
||||
@JsonProperty("userid") String userId,
|
||||
@JsonProperty("profileid") int profileId,
|
||||
@JsonProperty("proof") String proof,
|
||||
@JsonProperty("sesskey") int sessionKey,
|
||||
@JsonProperty("id") int sequenceId) {}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package entralinked.network.gamespy.message;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface GameSpyMessage {
|
||||
|
||||
public String name();
|
||||
public String value() default ""; // Still not sure what purpose this serves..
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package entralinked.network.gamespy.message;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
|
||||
import entralinked.model.user.GameProfile;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
@GameSpyMessage(name = "pi")
|
||||
public record GameSpyProfileResponse(
|
||||
@JsonProperty("profileid") int profileId,
|
||||
@JsonProperty("firstname") String firstName,
|
||||
@JsonProperty("lastname") String lastName,
|
||||
@JsonProperty("aim") String aimName,
|
||||
@JsonProperty("zipcode") String zipCode,
|
||||
@JsonProperty("sig") String signature,
|
||||
@JsonProperty("id") int sequenceId) {
|
||||
|
||||
public GameSpyProfileResponse(GameProfile profile, int sequenceId) {
|
||||
this(profile.getId(), profile.getFirstName(), profile.getLastName(), profile.getAimName(), profile.getZipCode(), "signature", sequenceId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
import entralinked.network.gamespy.message.GameSpyMessage;
|
||||
|
||||
@GameSpyMessage(name = "ka")
|
||||
public record GameSpyKeepAliveRequest() implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
handler.sendMessage(this); // Pong it back
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
public record GameSpyLoginRequest(
|
||||
// Important stuff
|
||||
@JsonProperty(value = "response", required = true) String response,
|
||||
@JsonProperty(value = "challenge", required = true) String challenge,
|
||||
@JsonProperty(value = "authtoken", required = true) String partnerToken,
|
||||
@JsonProperty(value = "id", required = true) int sequenceId,
|
||||
|
||||
// Not-so-important stuff
|
||||
@JsonProperty("userid") String userId,
|
||||
@JsonProperty("gamename") String gameName,
|
||||
@JsonProperty("profileid") int profileId,
|
||||
@JsonProperty("namespaceid") int namespaceId,
|
||||
@JsonProperty("partnerid") int partnerId,
|
||||
@JsonProperty("productid") int productId,
|
||||
@JsonProperty("sdkrevision") int sdkRevision,
|
||||
@JsonProperty("firewall") int firewall,
|
||||
@JsonProperty("port") int port,
|
||||
@JsonProperty("quiet") int quiet
|
||||
) implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
handler.handleLoginRequest(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
public record GameSpyLogoutRequest(
|
||||
@JsonProperty("sesskey") int sessionKey
|
||||
) implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
handler.destroySessionKey(sessionKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
public record GameSpyProfileRequest(
|
||||
@JsonProperty(value = "sesskey", required = true) int sessionKey,
|
||||
@JsonProperty(value = "id", required = true) int sequenceId,
|
||||
@JsonProperty("profileid") int profileId
|
||||
) implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
if(handler.validateSessionKey(sessionKey, sequenceId)) {
|
||||
handler.handleProfileRequest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
public record GameSpyProfileUpdateRequest(
|
||||
@JsonProperty(value = "sesskey", required = true) int sessionKey,
|
||||
@JsonProperty("partnerid") int partnerId,
|
||||
|
||||
// Lots of possible values here, but these seem to be the only ones sent by generation 5 games.
|
||||
@JsonProperty("firstname") String firstName,
|
||||
@JsonProperty("lastname") String lastName,
|
||||
@JsonProperty("aim") String aimName,
|
||||
@JsonProperty("zipcode") String zipCode
|
||||
) implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
if(handler.validateSessionKey(sessionKey)) {
|
||||
handler.handleUpdateProfileRequest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
@JsonSubTypes({
|
||||
@Type(name = "ka", value = GameSpyKeepAliveRequest.class),
|
||||
@Type(name = "login", value = GameSpyLoginRequest.class),
|
||||
@Type(name = "logout", value = GameSpyLogoutRequest.class),
|
||||
@Type(name = "status", value = GameSpyStatusRequest.class),
|
||||
@Type(name = "getprofile", value = GameSpyProfileRequest.class),
|
||||
@Type(name = "updatepro", value = GameSpyProfileUpdateRequest.class),
|
||||
})
|
||||
public interface GameSpyRequest {
|
||||
|
||||
public void process(GameSpyHandler handler);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package entralinked.network.gamespy.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import entralinked.network.gamespy.GameSpyHandler;
|
||||
|
||||
public record GameSpyStatusRequest(
|
||||
@JsonProperty(value = "sesskey", required = true) int sessionKey,
|
||||
@JsonProperty("statstring") String statusString,
|
||||
@JsonProperty("locstring") String locationString
|
||||
) implements GameSpyRequest {
|
||||
|
||||
@Override
|
||||
public void process(GameSpyHandler handler) {
|
||||
handler.validateSessionKey(sessionKey);
|
||||
}
|
||||
}
|
||||
16
src/main/java/entralinked/network/http/HttpHandler.java
Normal file
16
src/main/java/entralinked/network/http/HttpHandler.java
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package entralinked.network.http;
|
||||
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.servlet.JavalinServletContext;
|
||||
|
||||
public interface HttpHandler {
|
||||
|
||||
public void addHandlers(Javalin javalin);
|
||||
|
||||
public default void configureJavalin(JavalinConfig config) {}
|
||||
public default void clearTasks(Context ctx) {
|
||||
((JavalinServletContext)ctx).getTasks().clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package entralinked.network.http;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import io.javalin.http.Context;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface HttpRequestHandler<T> {
|
||||
|
||||
public void process(T request, Context ctx) throws IOException;
|
||||
}
|
||||
170
src/main/java/entralinked/network/http/HttpServer.java
Normal file
170
src/main/java/entralinked/network/http/HttpServer.java
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package entralinked.network.http;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.server.HttpConfiguration;
|
||||
import org.eclipse.jetty.server.HttpConnectionFactory;
|
||||
import org.eclipse.jetty.server.SecureRequestCustomizer;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.ServerConnector;
|
||||
import org.eclipse.jetty.server.SslConnectionFactory;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.LauncherAgent;
|
||||
import entralinked.utility.CertificateGenerator;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import io.javalin.util.JavalinException;
|
||||
|
||||
public class HttpServer {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static final String keyStorePassword = "password"; // Very secure!
|
||||
private final Javalin javalin;
|
||||
private boolean started;
|
||||
|
||||
public HttpServer(Entralinked entralinked) {
|
||||
// Create certificate keystore
|
||||
KeyStore keyStore = null;
|
||||
|
||||
if(LauncherAgent.isBouncyCastlePresent()) {
|
||||
CertificateGenerator.initialize();
|
||||
logger.info("Creating certificate keystore ...");
|
||||
keyStore = createKeyStore();
|
||||
|
||||
if(keyStore == null) {
|
||||
logger.warn("SSL will be disabled because keystore creation failed. You may have to manually sign a certificate.");
|
||||
}
|
||||
}
|
||||
|
||||
KeyStore _keyStore = keyStore; // Java moment
|
||||
|
||||
// Create Javalin instance
|
||||
javalin = Javalin.create(config -> {
|
||||
config.jetty.server(() -> createJettyServer(80, 443, _keyStore));
|
||||
});
|
||||
|
||||
// Create exception handler
|
||||
javalin.exception(Exception.class, (exception, ctx) -> {
|
||||
logger.error("Caught exception", exception);
|
||||
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
|
||||
// Conntest
|
||||
javalin.get("/", ctx -> {
|
||||
ctx.header("X-Organization", "Nintendo"); // Conntest fails with 052210-1 if this is not present
|
||||
ctx.result("Test");
|
||||
});
|
||||
}
|
||||
|
||||
public void addHandler(HttpHandler handler) {
|
||||
javalin.updateConfig(handler::configureJavalin); // Dirty
|
||||
handler.addHandlers(javalin);
|
||||
}
|
||||
|
||||
public boolean start() {
|
||||
if(started) {
|
||||
logger.warn("start() was called while HTTP server was already running!");
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("Starting HTTP server ...");
|
||||
|
||||
try {
|
||||
javalin.start();
|
||||
} catch(JavalinException e) {
|
||||
logger.error("Could not start HTTP server", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean stop() {
|
||||
if(!started) {
|
||||
logger.warn("stop() was called while HTTP server wasn't running!");
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("Stopping HTTP server ...");
|
||||
|
||||
try {
|
||||
javalin.stop();
|
||||
} catch(JavalinException e) {
|
||||
logger.error("Could not stop HTTP server", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
started = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private KeyStore createKeyStore() {
|
||||
try {
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
File keyStoreFile = new File("server.p12");
|
||||
|
||||
if(keyStoreFile.exists()) {
|
||||
// Load keystore from file if it exists
|
||||
logger.info("Cached keystore found - loading it!");
|
||||
keyStore.load(new FileInputStream(keyStoreFile), keyStorePassword.toCharArray());
|
||||
} else {
|
||||
// Otherwise, generate a new one and store it in a file
|
||||
logger.info("No keystore found - generating one!");
|
||||
keyStore = CertificateGenerator.generateCertificateKeyStore("PKCS12", null);
|
||||
keyStore.store(new FileOutputStream(keyStoreFile), keyStorePassword.toCharArray());
|
||||
}
|
||||
|
||||
return keyStore;
|
||||
} catch (GeneralSecurityException | IOException e) {
|
||||
logger.error("Could not create keystore", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Server createJettyServer(int port, int sslPort, KeyStore keyStore) {
|
||||
Server server = new Server();
|
||||
|
||||
// Regular HTTP connector
|
||||
ServerConnector httpConnector = new ServerConnector(server);
|
||||
httpConnector.setPort(port);
|
||||
server.addConnector(httpConnector);
|
||||
|
||||
if(keyStore != null) {
|
||||
// Create SSL/HTTPS connector if a keystore is present
|
||||
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
|
||||
sslContextFactory.setIncludeProtocols("SSLv3");
|
||||
sslContextFactory.setExcludeProtocols("");
|
||||
sslContextFactory.setIncludeCipherSuites("SSL_RSA_WITH_RC4_128_SHA", "SSL_RSA_WITH_RC4_128_MD5");
|
||||
sslContextFactory.setExcludeCipherSuites("");
|
||||
sslContextFactory.setKeyStore(keyStore);
|
||||
sslContextFactory.setKeyStorePassword(keyStorePassword);
|
||||
sslContextFactory.setSslSessionCacheSize(0);
|
||||
sslContextFactory.setSslSessionTimeout(0);
|
||||
|
||||
HttpConfiguration httpsConfiguration = new HttpConfiguration();
|
||||
httpsConfiguration.addCustomizer(new SecureRequestCustomizer(false));
|
||||
httpsConfiguration.setSendServerVersion(true);
|
||||
|
||||
ServerConnector httpsConnector = new ServerConnector(server,
|
||||
new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
|
||||
new HttpConnectionFactory(httpsConfiguration));
|
||||
|
||||
httpsConnector.setPort(sslPort);
|
||||
server.addConnector(httpsConnector);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package entralinked.network.http.dashboard;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.model.dlc.Dlc;
|
||||
import entralinked.model.dlc.DlcList;
|
||||
import entralinked.model.pkmn.PkmnGender;
|
||||
import entralinked.model.pkmn.PkmnInfo;
|
||||
import entralinked.model.player.DreamEncounter;
|
||||
import entralinked.model.player.DreamItem;
|
||||
import entralinked.model.player.Player;
|
||||
import entralinked.model.player.PlayerManager;
|
||||
import entralinked.model.player.PlayerStatus;
|
||||
import entralinked.network.http.HttpHandler;
|
||||
import entralinked.utility.GsidUtility;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import io.javalin.http.staticfiles.Location;
|
||||
import io.javalin.json.JavalinJackson;
|
||||
|
||||
/**
|
||||
* HTTP handler for requests made to the user dashboard.
|
||||
*/
|
||||
public class DashboardHandler implements HttpHandler {
|
||||
|
||||
private final DlcList dlcList;
|
||||
private final PlayerManager playerManager;
|
||||
|
||||
public DashboardHandler(Entralinked entralinked) {
|
||||
this.dlcList = entralinked.getDlcList();
|
||||
this.playerManager = entralinked.getPlayerManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addHandlers(Javalin javalin) {
|
||||
javalin.get("/dashboard/dlc", this::handleRetrieveDlcList);
|
||||
javalin.get("/dashboard/profile", this::handleRetrieveProfile);
|
||||
javalin.post("/dashboard/profile", this::handleUpdateProfile);
|
||||
javalin.post("/dashboard/login", this::handleLogin);
|
||||
javalin.post("/dashboard/logout", this::handleLogout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureJavalin(JavalinConfig config) {
|
||||
// Configure JSON mapper
|
||||
config.jsonMapper(new JavalinJackson(new ObjectMapper()));
|
||||
|
||||
// Add dashboard pages
|
||||
config.staticFiles.add(staticFileConfig -> {
|
||||
staticFileConfig.location = Location.CLASSPATH;
|
||||
staticFileConfig.directory = "/dashboard";
|
||||
staticFileConfig.hostedPath = "/dashboard";
|
||||
});
|
||||
|
||||
// Add sprites
|
||||
config.staticFiles.add(staticFileConfig -> {
|
||||
staticFileConfig.location = Location.CLASSPATH;
|
||||
staticFileConfig.directory = "/sprites";
|
||||
staticFileConfig.hostedPath = "/sprites";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request handler for {@code /dashboard/dlc}
|
||||
*/
|
||||
private void handleRetrieveDlcList(Context ctx) {
|
||||
// Make sure that the DLC type is present
|
||||
String type = ctx.queryParam("type");
|
||||
|
||||
if(type == null) {
|
||||
ctx.json(Collections.EMPTY_LIST);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send result
|
||||
ctx.json(dlcList.getDlcList("IRAO", type).stream().map(Dlc::name).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request handler for {@code /dashboard/profile}
|
||||
*/
|
||||
private void handleRetrieveProfile(Context ctx) {
|
||||
// Validate session
|
||||
Player player = ctx.sessionAttribute("player");
|
||||
|
||||
if(player == null || player.getStatus() == PlayerStatus.AWAKE) {
|
||||
ctx.json(new DashboardStatusMessage("Unauthorized", true));
|
||||
ctx.status(HttpStatus.UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send profile data
|
||||
ctx.json(new DashboardProfileMessage(getSpritePath(player.getDreamerInfo()), player));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request handler for {@code /dashboard/login}
|
||||
*/
|
||||
private void handleLogin(Context ctx) {
|
||||
String gsid = ctx.formParam("gsid");
|
||||
|
||||
// Check if the Game Sync ID is valid
|
||||
if(gsid == null || !GsidUtility.isValidGameSyncId(gsid)) {
|
||||
ctx.json(new DashboardStatusMessage("Please enter a valid Game Sync ID.", true));
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = playerManager.getPlayer(gsid);
|
||||
|
||||
// Check if the Game Sync ID exists
|
||||
if(player == null) {
|
||||
ctx.json(new DashboardStatusMessage("This Game Sync ID does not exist.", true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is stuff to play around with
|
||||
if(player.getStatus() == PlayerStatus.AWAKE) {
|
||||
ctx.json(new DashboardStatusMessage("Please use Game Sync to tuck in a Pokémon before proceeding.", true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store session attribute and send response
|
||||
ctx.sessionAttribute("player", player);
|
||||
ctx.json(new DashboardStatusMessage("ok")); // heh
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request handler for {@code /dashboard/logout}
|
||||
*/
|
||||
private void handleLogout(Context ctx) {
|
||||
// Who cares if the session actually exists? I sure don't.
|
||||
ctx.consumeSessionAttribute("player");
|
||||
ctx.json(Collections.EMPTY_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request handler for {@code /dashboard/profile}
|
||||
*/
|
||||
private void handleUpdateProfile(Context ctx) {
|
||||
// Check if session exists
|
||||
Player player = ctx.sessionAttribute("player");
|
||||
|
||||
if(player == null || player.getStatus() == PlayerStatus.AWAKE) {
|
||||
ctx.json(new DashboardStatusMessage("Unauthorized", true));
|
||||
ctx.status(HttpStatus.UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate request data
|
||||
DashboardProfileUpdateRequest request = ctx.bodyAsClass(DashboardProfileUpdateRequest.class);
|
||||
String error = validateProfileUpdateRequest(player, request);
|
||||
|
||||
if(error != null) {
|
||||
ctx.json(new DashboardStatusMessage("Profile data was NOT saved: " + error, true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update profile
|
||||
player.setStatus(PlayerStatus.WAKE_READY);
|
||||
player.setEncounters(request.encounters());
|
||||
player.setItems(request.items());
|
||||
player.setCGearSkin(request.cgearSkin().equals("none") ? null : request.cgearSkin());
|
||||
player.setDexSkin(request.dexSkin().equals("none") ? null : request.dexSkin());
|
||||
player.setMusical(request.musical().equals("none") ? null : request.musical());
|
||||
player.setLevelsGained(request.gainedLevels());
|
||||
|
||||
// Try to save profile data
|
||||
if(!playerManager.savePlayer(player)) {
|
||||
ctx.json(new DashboardStatusMessage("Profile data could not be saved because of an error.", true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send response if all succeeded
|
||||
ctx.json(new DashboardStatusMessage("Your changes have been saved. Use Game Sync to wake up your Pokémon and download your selected content."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a {@link DashboardProfileUpdateRequest} and returns an error string if the data is invalid.
|
||||
* If the data is valid, {@code null} is returned instead.
|
||||
*/
|
||||
private String validateProfileUpdateRequest(Player player, DashboardProfileUpdateRequest request) {
|
||||
// Validate encounters
|
||||
if(request.encounters().size() > 10) {
|
||||
return "Encounter list size exceeds the limit.";
|
||||
}
|
||||
|
||||
for(DreamEncounter encounter : request.encounters()) {
|
||||
if(encounter.species() < 1 || encounter.species() > 493) {
|
||||
return "Species is out of range.";
|
||||
} else if(encounter.move() < 1 || encounter.move() > 559) {
|
||||
return "Move ID is out of range.";
|
||||
} else if(encounter.animation() == null) {
|
||||
return "Animation is undefined.";
|
||||
}
|
||||
|
||||
// TODO validate form maybe idk
|
||||
}
|
||||
|
||||
// Validate items
|
||||
if(request.items().size() > 20) {
|
||||
return "Item list size exceeds the limit.";
|
||||
}
|
||||
|
||||
for(DreamItem item : request.items()) {
|
||||
if(item.id() < 0 || item.id() > 638) {
|
||||
return "Item ID is out of range";
|
||||
} else if(item.id() > 626 && !player.getGameVersion().isVersion2()) {
|
||||
return "You have selected one or more items that are exclusive to Black Version 2 and White Version 2.";
|
||||
}
|
||||
|
||||
if(item.quantity() < 0 || item.quantity() > 20) {
|
||||
return "Item quantity is out of range.";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate gained levels
|
||||
if(request.gainedLevels() < 0 || request.gainedLevels() > 99) {
|
||||
return "Gained levels is out of range.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getSpritePath(PkmnInfo info) {
|
||||
String basePath = "/sprites/pokemon/%s".formatted(info.isShiny() ? "shiny" : "normal");
|
||||
String path = null;
|
||||
|
||||
if(info.form() > 0) {
|
||||
path = "%s/%s-%s.png".formatted(basePath, info.species(), info.form());
|
||||
} else if(info.gender() == PkmnGender.FEMALE) {
|
||||
path = "%s/female/%s.png".formatted(basePath, info.species());
|
||||
}
|
||||
|
||||
if(path == null || getClass().getResource(path) == null) {
|
||||
return "%s/%s.png".formatted(basePath, info.species());
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package entralinked.network.http.dashboard;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import entralinked.model.pkmn.PkmnInfo;
|
||||
import entralinked.model.player.DreamEncounter;
|
||||
import entralinked.model.player.DreamItem;
|
||||
import entralinked.model.player.Player;
|
||||
|
||||
public record DashboardProfileMessage(
|
||||
String gameVersion,
|
||||
String dreamerSprite,
|
||||
PkmnInfo dreamerInfo,
|
||||
String cgearSkin,
|
||||
String dexSkin,
|
||||
String musical,
|
||||
int levelsGained,
|
||||
Collection<DreamEncounter> encounters,
|
||||
Collection<DreamItem> items) {
|
||||
|
||||
public DashboardProfileMessage(String dreamerSprite, Player player) {
|
||||
this(player.getGameVersion().getDisplayName(), dreamerSprite, player.getDreamerInfo(), player.getCGearSkin(),
|
||||
player.getDexSkin(), player.getMusical(), player.getLevelsGained(), player.getEncounters(), player.getItems());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package entralinked.network.http.dashboard;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
import entralinked.model.player.DreamEncounter;
|
||||
import entralinked.model.player.DreamItem;
|
||||
|
||||
public record DashboardProfileUpdateRequest(
|
||||
@JsonProperty(required = true) @JsonDeserialize(contentAs = DreamEncounter.class) List<DreamEncounter> encounters,
|
||||
@JsonProperty(required = true) @JsonDeserialize(contentAs = DreamItem.class) List<DreamItem> items,
|
||||
@JsonProperty(required = true) String cgearSkin,
|
||||
@JsonProperty(required = true) String dexSkin,
|
||||
@JsonProperty(required = true) String musical,
|
||||
@JsonProperty(required = true) int gainedLevels) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package entralinked.network.http.dashboard;
|
||||
|
||||
public record DashboardStatusMessage(String message, boolean error) {
|
||||
|
||||
public DashboardStatusMessage(String message) {
|
||||
this(message, false);
|
||||
}
|
||||
}
|
||||
106
src/main/java/entralinked/network/http/dls/DlsHandler.java
Normal file
106
src/main/java/entralinked/network/http/dls/DlsHandler.java
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package entralinked.network.http.dls;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.model.dlc.Dlc;
|
||||
import entralinked.model.dlc.DlcList;
|
||||
import entralinked.model.user.ServiceSession;
|
||||
import entralinked.model.user.UserManager;
|
||||
import entralinked.network.http.HttpHandler;
|
||||
import entralinked.network.http.HttpRequestHandler;
|
||||
import entralinked.serialization.UrlEncodedFormFactory;
|
||||
import entralinked.utility.LEOutputStream;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* HTTP handler for requests made to {@code dls1.nintendowifi.net}
|
||||
*/
|
||||
public class DlsHandler implements HttpHandler {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory());
|
||||
private final DlcList dlcList;
|
||||
private final UserManager userManager;
|
||||
|
||||
public DlsHandler(Entralinked entralinked) {
|
||||
this.dlcList = entralinked.getDlcList();
|
||||
this.userManager = entralinked.getUserManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addHandlers(Javalin javalin) {
|
||||
javalin.post("/download", this::handleDownloadRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST base handler for {@code /download}
|
||||
*/
|
||||
private void handleDownloadRequest(Context ctx) throws IOException {
|
||||
// Deserialize request body
|
||||
DlsRequest request = mapper.readValue(ctx.body().replace("%2A", "*"), DlsRequest.class);
|
||||
|
||||
// Check if service session is valid
|
||||
ServiceSession session = userManager.getServiceSession(request.serviceToken(), "dls1.nintendowifi.net");
|
||||
|
||||
if(session == null) {
|
||||
ctx.status(HttpStatus.UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine handler function based on request action
|
||||
HttpRequestHandler<DlsRequest> handler = switch(request.action()) {
|
||||
case "list" -> this::handleRetrieveDlcList;
|
||||
case "contents" -> this::handleRetrieveDlcContent;
|
||||
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
|
||||
};
|
||||
|
||||
// Handle the request
|
||||
handler.process(request, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /download action=list}
|
||||
*/
|
||||
private void handleRetrieveDlcList(DlsRequest request, Context ctx) throws IOException {
|
||||
// Map to generic type, I doubt there is a real difference between the language codes anyway.
|
||||
String type = switch(request.dlcType()) {
|
||||
case "CGEAR_E", "CGEAR_F", "CGEAR_I", "CGEAR_G", "CGEAR_S", "CGEAR_J", "CGEAR_K" -> "CGEAR";
|
||||
case "CGEAR2_E", "CGEAR2_F", "CGEAR2_I", "CGEAR2_G", "CGEAR2_S", "CGEAR2_J", "CGEAR2_K" -> "CGEAR2";
|
||||
case "ZUKAN_E", "ZUKAN_F", "ZUKAN_I", "ZUKAN_G", "ZUKAN_S", "ZUKAN_J", "ZUKAN_K" -> "ZUKAN";
|
||||
case "MUSICAL_E", "MUSICAL_F", "MUSICAL_I", "MUSICAL_G", "MUSICAL_S", "MUSICAL_J", "MUSICAL_K" -> "MUSICAL";
|
||||
default -> request.dlcType();
|
||||
};
|
||||
|
||||
// TODO NOTE: I assume that in a conventional implementation, certain DLC attributes may be omitted from the request.
|
||||
ctx.result(dlcList.getDlcListString(dlcList.getDlcList(request.dlcGameCode(), type, request.dlcIndex())));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /download action=contents}
|
||||
*/
|
||||
private void handleRetrieveDlcContent(DlsRequest request, Context ctx) throws IOException {
|
||||
// Check if the requested DLC exists
|
||||
Dlc dlc = dlcList.getDlc(request.dlcName());
|
||||
|
||||
if(dlc == null) {
|
||||
ctx.status(HttpStatus.NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write DLC data
|
||||
try(FileInputStream inputStream = new FileInputStream(dlc.path())) {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
inputStream.transferTo(outputStream);
|
||||
|
||||
// If checksum is not part of the file, manually append it
|
||||
if(!dlc.checksumEmbedded()) {
|
||||
outputStream.writeShort(dlc.checksum());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/main/java/entralinked/network/http/dls/DlsRequest.java
Normal file
25
src/main/java/entralinked/network/http/dls/DlsRequest.java
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package entralinked.network.http.dls;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record DlsRequest(
|
||||
// Credentials
|
||||
@JsonProperty(value = "userid", required = true) String userId,
|
||||
@JsonProperty(value = "passwd", required = true) String password,
|
||||
@JsonProperty(value = "macadr", required = true) String macAddress,
|
||||
@JsonProperty(value = "token", required = true) String serviceToken,
|
||||
|
||||
// Game info
|
||||
@JsonProperty("rhgamecd") String gameCode,
|
||||
|
||||
// Device info
|
||||
@JsonProperty("apinfo") String accessPointInfo,
|
||||
|
||||
// Request-specific info
|
||||
@JsonProperty(value = "action", required = true) String action,
|
||||
@JsonProperty("gamecd") String dlcGameCode,
|
||||
@JsonProperty("contents") String dlcName, // action=contents
|
||||
@JsonProperty("attr1") String dlcType, // action=list
|
||||
@JsonProperty("attr2") int dlcIndex, // action=list
|
||||
@JsonProperty("offset") int offset, // ?
|
||||
@JsonProperty("num") int num) {} // ?
|
||||
146
src/main/java/entralinked/network/http/nas/NasHandler.java
Normal file
146
src/main/java/entralinked/network/http/nas/NasHandler.java
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
|
||||
import entralinked.Configuration;
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.model.user.ServiceCredentials;
|
||||
import entralinked.model.user.User;
|
||||
import entralinked.model.user.UserManager;
|
||||
import entralinked.network.http.HttpHandler;
|
||||
import entralinked.network.http.HttpRequestHandler;
|
||||
import entralinked.serialization.UrlEncodedFormFactory;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
/**
|
||||
* HTTP handler for requests made to {@code nas.nintendowifi.net}
|
||||
*/
|
||||
public class NasHandler implements HttpHandler {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()).registerModule(new JavaTimeModule());
|
||||
private final Configuration configuration;
|
||||
private final UserManager userManager;
|
||||
|
||||
public NasHandler(Entralinked entralinked) {
|
||||
this.configuration = entralinked.getConfiguration();
|
||||
this.userManager = entralinked.getUserManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addHandlers(Javalin javalin) {
|
||||
javalin.post("/ac", this::handleNasRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST base handler for {@code /ac}
|
||||
* Deserializes requests and processes them accordingly.
|
||||
*/
|
||||
private void handleNasRequest(Context ctx) throws IOException {
|
||||
// Deserialize body into a request object
|
||||
NasRequest request = mapper.readValue(ctx.body(), NasRequest.class);
|
||||
|
||||
// Determine handler function based on request action
|
||||
HttpRequestHandler<NasRequest> handler = switch(request.action()) {
|
||||
case "login" -> this::handleLogin;
|
||||
case "acctcreate" -> this::handleCreateAccount;
|
||||
case "SVCLOC" -> this::handleRetrieveServiceLocation;
|
||||
default -> throw new IllegalArgumentException("Invalid POST request action: " + request.action());
|
||||
};
|
||||
|
||||
// Process the request
|
||||
handler.process(request, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /ac action=login}
|
||||
*/
|
||||
private void handleLogin(NasRequest request, Context ctx) throws IOException {
|
||||
// Make sure branch code is present
|
||||
if(request.branchCode() == null) {
|
||||
result(ctx, NasReturnCode.BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
String userId = request.userId();
|
||||
User user = userManager.authenticateUser(userId, request.password());
|
||||
|
||||
// Check if user exists
|
||||
if(user == null) {
|
||||
if(!configuration.allowWfcRegistrationThroughLogin()) {
|
||||
result(ctx, NasReturnCode.USER_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to register, if the configuration allows it
|
||||
if(!UserManager.isValidUserId(userId) || userManager.doesUserExist(userId)
|
||||
|| !userManager.registerUser(userId, request.password())) {
|
||||
// Oh well, try again!
|
||||
result(ctx, NasReturnCode.USER_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
// Should *never* return null in this location
|
||||
user = userManager.authenticateUser(userId, request.password());
|
||||
}
|
||||
|
||||
// Prepare GameSpy server credentials
|
||||
ServiceCredentials credentials = userManager.createServiceSession(user, "gamespy", request.branchCode());
|
||||
result(ctx, new NasLoginResponse("gamespy.com", credentials.authToken(), credentials.challenge()));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /ac action=acctcreate}
|
||||
*/
|
||||
private void handleCreateAccount(NasRequest request, Context ctx) throws IOException {
|
||||
String userId = request.userId();
|
||||
|
||||
// Check if user ID is invalid or duplicate
|
||||
if(!UserManager.isValidUserId(userId) || userManager.doesUserExist(userId)) {
|
||||
result(ctx, NasReturnCode.USER_ALREADY_EXISTS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to register user
|
||||
if(!userManager.registerUser(userId, request.password())) {
|
||||
result(ctx, NasReturnCode.INTERNAL_SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
result(ctx, NasReturnCode.REGISTRATION_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /ac action=SVCLOC}
|
||||
*/
|
||||
private void handleRetrieveServiceLocation(NasRequest request, Context ctx) throws IOException {
|
||||
// Determine service location from type
|
||||
String service = switch(request.serviceType()) {
|
||||
case "0000" -> "external"; // External game-specific service
|
||||
case "9000" -> "dls1.nintendowifi.net"; // Download server
|
||||
default -> throw new IllegalArgumentException("Invalid service type: " + request.serviceType());
|
||||
};
|
||||
|
||||
// Prepare user credentials
|
||||
ServiceCredentials credentials = userManager.createServiceSession(null, service, null);
|
||||
result(ctx, new NasServiceLocationResponse(true, service, credentials.authToken()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets context result to the specified response serialized as a URL encoded form.
|
||||
*/
|
||||
private void result(Context ctx, NasResponse response) throws IOException {
|
||||
ctx.result(mapper.writeValueAsString(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@link #result(Context, NasResponse)} where {@code NasResponse} is a {@link NasStatusResponse}
|
||||
* with the specified return code as its parameter.
|
||||
*/
|
||||
private void result(Context ctx, NasReturnCode returnCode) throws IOException {
|
||||
result(ctx, new NasStatusResponse(returnCode));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record NasLoginResponse(
|
||||
@JsonProperty("locator") String partner,
|
||||
@JsonProperty("token") String partnerToken,
|
||||
@JsonProperty("challenge") String partnerChallenge
|
||||
) implements NasResponse {}
|
||||
33
src/main/java/entralinked/network/http/nas/NasRequest.java
Normal file
33
src/main/java/entralinked/network/http/nas/NasRequest.java
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.MonthDay;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record NasRequest(
|
||||
// Credentials
|
||||
@JsonProperty(value = "userid", required = true) String userId,
|
||||
@JsonProperty(value = "passwd", required = true) String password,
|
||||
@JsonProperty(value = "macadr", required = true) String macAddress,
|
||||
|
||||
// Game info
|
||||
@JsonProperty("gamecd") String gameCode,
|
||||
@JsonProperty("makercd") String makerCode,
|
||||
@JsonProperty("unitcd") String unitCode,
|
||||
@JsonProperty("sdkver") String sdkVersion,
|
||||
@JsonProperty("lang") String language,
|
||||
|
||||
// Device info
|
||||
@JsonProperty("bssid") String bssid,
|
||||
@JsonProperty("apinfo") String accessPointInfo,
|
||||
@JsonProperty("devname") String deviceName,
|
||||
@JsonProperty("devtime") @JsonFormat(shape = Shape.STRING, pattern = "yyMMddHHmmss") LocalDateTime deviceTime,
|
||||
@JsonProperty("birth") @JsonFormat(shape = Shape.STRING, pattern = "MMdd") MonthDay birthDate,
|
||||
|
||||
// Request-specific info
|
||||
@JsonProperty(value = "action", required = true) String action,
|
||||
@JsonProperty("gsbrcd") String branchCode, // action=login
|
||||
@JsonProperty("svc") String serviceType) {} // action=SVCLOC
|
||||
21
src/main/java/entralinked/network/http/nas/NasResponse.java
Normal file
21
src/main/java/entralinked/network/http/nas/NasResponse.java
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
|
||||
|
||||
public interface NasResponse {
|
||||
|
||||
@JsonProperty("returncd")
|
||||
default NasReturnCode returnCode() {
|
||||
return NasReturnCode.SUCCESS;
|
||||
}
|
||||
|
||||
@JsonProperty("datetime")
|
||||
@JsonFormat(shape = Shape.STRING, pattern = "yyMMddHHmmss")
|
||||
default LocalDateTime dateTime() {
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* List of NAS return codes ({@code returncd})
|
||||
*/
|
||||
public enum NasReturnCode {
|
||||
|
||||
/**
|
||||
* (Generic) Action was successful.
|
||||
*/
|
||||
SUCCESS(1),
|
||||
|
||||
/**
|
||||
* (Generic) An error occured on the server whilst processing a request.
|
||||
*/
|
||||
INTERNAL_SERVER_ERROR(100),
|
||||
|
||||
/**
|
||||
* (Registration) User account creation was successful.
|
||||
* When this is sent, the user ID (WFC ID) will be stored on the client device.
|
||||
*/
|
||||
REGISTRATION_SUCCESS(2),
|
||||
|
||||
/**
|
||||
* (Generic) Request is missing data.
|
||||
*/
|
||||
BAD_REQUEST(102),
|
||||
|
||||
/**
|
||||
* (Registration) The client tried to register a user ID that already exists on the server.
|
||||
* When this is sent, the client will generate a new user ID and try again.
|
||||
*/
|
||||
USER_ALREADY_EXISTS(104),
|
||||
|
||||
/**
|
||||
* (Login) The user data has been deleted from the server.
|
||||
*/
|
||||
USER_EXPIRED(108),
|
||||
|
||||
/**
|
||||
* (Login) The user with the client-specified user ID does not exist
|
||||
*/
|
||||
USER_NOT_FOUND(204);
|
||||
|
||||
private final int clientId;
|
||||
|
||||
private NasReturnCode(int clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getFormattedClientId() {
|
||||
return "%03d".formatted(clientId);
|
||||
}
|
||||
|
||||
public int getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public record NasServiceLocationResponse(
|
||||
@JsonProperty("statusdata") char serviceStatus, // If 0x59 ('Y') sets flag to true. Game doesn't care when connecting to GL server, though.
|
||||
@JsonProperty("svchost") String serviceHost,
|
||||
@JsonProperty("servicetoken") String serviceToken
|
||||
) implements NasResponse {
|
||||
|
||||
public NasServiceLocationResponse(boolean serviceAvailable, String serviceHost, String serviceToken) {
|
||||
this(serviceAvailable ? 'Y' : 'N', serviceHost, serviceToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package entralinked.network.http.nas;
|
||||
|
||||
public record NasStatusResponse(NasReturnCode returnCode) implements NasResponse {}
|
||||
382
src/main/java/entralinked/network/http/pgl/PglHandler.java
Normal file
382
src/main/java/entralinked/network/http/pgl/PglHandler.java
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package entralinked.network.http.pgl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import entralinked.Configuration;
|
||||
import entralinked.Entralinked;
|
||||
import entralinked.model.dlc.DlcList;
|
||||
import entralinked.model.pkmn.PkmnInfo;
|
||||
import entralinked.model.pkmn.PkmnInfoReader;
|
||||
import entralinked.model.player.DreamEncounter;
|
||||
import entralinked.model.player.DreamItem;
|
||||
import entralinked.model.player.Player;
|
||||
import entralinked.model.player.PlayerManager;
|
||||
import entralinked.model.player.PlayerStatus;
|
||||
import entralinked.model.user.ServiceSession;
|
||||
import entralinked.model.user.UserManager;
|
||||
import entralinked.network.http.HttpHandler;
|
||||
import entralinked.network.http.HttpRequestHandler;
|
||||
import entralinked.serialization.UrlEncodedFormFactory;
|
||||
import entralinked.serialization.UrlEncodedFormParser;
|
||||
import entralinked.utility.LEOutputStream;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import io.javalin.security.BasicAuthCredentials;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
|
||||
/**
|
||||
* HTTP handler for requests made to {@code en.pokemon-gl.com}
|
||||
*/
|
||||
public class PglHandler implements HttpHandler {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static final String username = "pokemon";
|
||||
private static final String password = "2Phfv9MY"; // Best security in the world
|
||||
private final ObjectMapper mapper = new ObjectMapper(new UrlEncodedFormFactory()
|
||||
.disable(UrlEncodedFormParser.Feature.BASE64_DECODE_VALUES));
|
||||
private final Set<Integer> sleepyList = new HashSet<>();
|
||||
private final Configuration configuration;
|
||||
private final DlcList dlcList;
|
||||
private final UserManager userManager;
|
||||
private final PlayerManager playerManager;
|
||||
|
||||
public PglHandler(Entralinked entralinked) {
|
||||
this.configuration = entralinked.getConfiguration();
|
||||
this.dlcList = entralinked.getDlcList();
|
||||
this.userManager = entralinked.getUserManager();
|
||||
this.playerManager = entralinked.getPlayerManager();
|
||||
|
||||
// Add all species to the sleepy list
|
||||
for(int i = 1; i <= 649; i++) {
|
||||
sleepyList.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addHandlers(Javalin javalin) {
|
||||
javalin.before("/dsio/gw", this::authorizePglRequest);
|
||||
javalin.get("/dsio/gw", this::handlePglGetRequest);
|
||||
javalin.post("/dsio/gw", this::handlePglPostRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* BEFORE handler for {@code /dsio/gw} that serves to deserialize and authenticate the request.
|
||||
* The deserialized request will be stored in a context attribute named {@code request} and may be retrieved
|
||||
* by subsequent handlers.
|
||||
*/
|
||||
private void authorizePglRequest(Context ctx) throws IOException {
|
||||
// Verify the authorization header credentials
|
||||
BasicAuthCredentials credentials = ctx.basicAuthCredentials();
|
||||
|
||||
if(credentials == null ||
|
||||
!username.equals(credentials.getUsername()) || !password.equals(credentials.getPassword())) {
|
||||
ctx.status(HttpStatus.UNAUTHORIZED);
|
||||
clearTasks(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deserialize the request
|
||||
PglRequest request = mapper.readValue(ctx.queryString(), PglRequest.class);
|
||||
|
||||
// Check game version
|
||||
if(request.gameVersion() == null) {
|
||||
ctx.status(HttpStatus.BAD_REQUEST);
|
||||
clearTasks(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the service session token
|
||||
ServiceSession session = userManager.getServiceSession(request.token(), "external");
|
||||
|
||||
if(session == null) {
|
||||
ctx.status(HttpStatus.UNAUTHORIZED);
|
||||
clearTasks(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store request object for subsequent handlers
|
||||
ctx.attribute("request", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET base handler for {@code /dsio/gw}
|
||||
*/
|
||||
private void handlePglGetRequest(Context ctx) throws IOException {
|
||||
PglRequest request = ctx.attribute("request");
|
||||
|
||||
// Determine request handler function based on type
|
||||
HttpRequestHandler<PglRequest> handler = switch(request.type()) {
|
||||
case "sleepily.bitlist" -> this::handleGetSleepyList;
|
||||
case "account.playstatus" -> this::handleGetAccountStatus;
|
||||
case "savedata.download" -> this::handleDownloadSaveData;
|
||||
default -> throw new IllegalArgumentException("Invalid GET request type: " + request.type());
|
||||
};
|
||||
|
||||
// Handle the request
|
||||
handler.process(request, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for {@code /dsio/gw?p=sleepily.bitlist}
|
||||
*/
|
||||
private void handleGetSleepyList(PglRequest request, Context ctx) throws IOException {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
|
||||
// Check if player exists
|
||||
if(!playerManager.doesPlayerExist(request.gameSyncId())) {
|
||||
writeStatusCode(outputStream, 1); // Unauthorized
|
||||
return;
|
||||
}
|
||||
|
||||
// Create bitlist
|
||||
byte[] bitlist = new byte[128]; // TODO pool? maybe just cache
|
||||
|
||||
for(int sleepy : sleepyList) {
|
||||
// 8 Pokémon (bits) in 1 byte
|
||||
int byteOffset = sleepy / 8;
|
||||
int bitOffset = sleepy % 8;
|
||||
|
||||
// Set the bit to 1!
|
||||
bitlist[byteOffset] |= 1 << bitOffset;
|
||||
}
|
||||
|
||||
// Send bitlist
|
||||
writeStatusCode(outputStream, 0);
|
||||
outputStream.write(bitlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for {@code /dsio/gw?p=account.playstatus}
|
||||
*
|
||||
* Black 2 - {@code sub_21B74B4} (overlay #199)
|
||||
*/
|
||||
private void handleGetAccountStatus(PglRequest request, Context ctx) throws IOException {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
|
||||
// Request account creation if one doesn't exist yet
|
||||
if(player == null) {
|
||||
writeStatusCode(outputStream, 8); // 5 is also handled separately, but doesn't seem to do anything unique
|
||||
return;
|
||||
}
|
||||
|
||||
writeStatusCode(outputStream, 0);
|
||||
outputStream.writeShort(player.getStatus().ordinal());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for {@code /dsio/gw?p=savedata.download}
|
||||
*
|
||||
* Black 2 - {@code sub_21B6C9C} (overlay #199)
|
||||
*/
|
||||
private void handleDownloadSaveData(PglRequest request, Context ctx) throws IOException {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
|
||||
// Check if player exists
|
||||
if(player == null) {
|
||||
writeStatusCode(outputStream, 1); // Unauthorized
|
||||
return;
|
||||
}
|
||||
|
||||
// Write status code
|
||||
writeStatusCode(outputStream, 0);
|
||||
|
||||
// Allow it to wake up anyway, maybe the poor sap is stuck..
|
||||
// Just don't send any other data.
|
||||
if(player.getStatus() == PlayerStatus.AWAKE) {
|
||||
logger.info("Player {} is downloading save data, but is already awake!", player.getGameSyncId());
|
||||
return;
|
||||
}
|
||||
|
||||
List<DreamEncounter> encounters = player.getEncounters();
|
||||
List<DreamItem> items = player.getItems();
|
||||
|
||||
// When waking up a Pokémon, these 4 bytes are written to 0x1D304 in the save file.
|
||||
// If the bytes in the game's save file match the new bytes, they will be set to 0x00000000
|
||||
// and no content will be downloaded.
|
||||
outputStream.writeInt((int)(Math.random() * Integer.MAX_VALUE));
|
||||
|
||||
// Write encounter data (max 10)
|
||||
for(DreamEncounter encounter : encounters) {
|
||||
outputStream.writeShort(encounter.species());
|
||||
outputStream.writeShort(encounter.move());
|
||||
outputStream.write(encounter.form());
|
||||
outputStream.write(0); // unknown
|
||||
outputStream.write(encounter.animation().ordinal());
|
||||
outputStream.write(0); // unknown
|
||||
}
|
||||
|
||||
// Write encounter padding
|
||||
outputStream.writeBytes(0, (10 - encounters.size()) * 8);
|
||||
|
||||
// Write misc stuff and DLC information
|
||||
outputStream.writeShort(player.getLevelsGained());
|
||||
outputStream.write(0); // Unknown
|
||||
outputStream.write(dlcList.getDlcIndex(player.getMusical()));
|
||||
outputStream.write(dlcList.getDlcIndex(player.getCGearSkin()));
|
||||
outputStream.write(dlcList.getDlcIndex(player.getDexSkin()));
|
||||
outputStream.write(0); // Unknown
|
||||
outputStream.write(0); // Must be zero?
|
||||
|
||||
// Write item IDs
|
||||
for(DreamItem item : items) {
|
||||
outputStream.writeShort(item.id());
|
||||
}
|
||||
|
||||
// Write item ID padding
|
||||
outputStream.writeBytes(0, (20 - items.size()) * 2);
|
||||
|
||||
// Write item quantities
|
||||
for(DreamItem item : items) {
|
||||
outputStream.write(item.quantity()); // Hard caps at 20?
|
||||
}
|
||||
|
||||
// Write quantity padding
|
||||
outputStream.writeBytes(0, (20 - items.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST base handler for {@code /dsio/gw}
|
||||
*/
|
||||
private void handlePglPostRequest(Context ctx) throws IOException {
|
||||
// Retrieve context attributes
|
||||
PglRequest request = ctx.attribute("request");
|
||||
|
||||
// Determine handler function based on request type
|
||||
HttpRequestHandler<PglRequest> handler = switch(request.type()) {
|
||||
case "savedata.upload" -> this::handleUploadSaveData;
|
||||
case "savedata.download.finish" -> this::handleDownloadSaveDataFinish;
|
||||
case "account.create.upload" -> this::handleCreateAccount;
|
||||
default -> throw new IllegalArgumentException("Invalid POST request type: " + request.type());
|
||||
};
|
||||
|
||||
handler.process(request, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /dsio/gw?p=savedata.download.finish}
|
||||
*/
|
||||
private void handleDownloadSaveDataFinish(PglRequest request, Context ctx) throws IOException {
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
|
||||
// Check if player exists
|
||||
if(player == null) {
|
||||
writeStatusCode(outputStream, 1); // Unauthorized
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset player dream information if configured to do so
|
||||
if(configuration.clearPlayerDreamInfoOnWake()) {
|
||||
player.resetDreamInfo();
|
||||
|
||||
// Try to save player data
|
||||
if(!playerManager.savePlayer(player)) {
|
||||
logger.warn("Save data failure for player {}", player.getGameSyncId());
|
||||
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// Write status code
|
||||
writeStatusCode(outputStream, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /dsio/gw?p=savedata.upload}
|
||||
*/
|
||||
private void handleUploadSaveData(PglRequest request, Context ctx) throws IOException {
|
||||
// Read save data
|
||||
ServletInputStream inputStream = ctx.req().getInputStream();
|
||||
inputStream.skip(0x1D300); // Skip to dream world data
|
||||
inputStream.skip(8); // Skip to Pokémon data
|
||||
PkmnInfo info = PkmnInfoReader.readPokeInfo(inputStream);
|
||||
|
||||
// Don't care about anything else -- continue reading bytes until we're done.
|
||||
while(!inputStream.isFinished()) {
|
||||
inputStream.read();
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
Player player = playerManager.getPlayer(request.gameSyncId());
|
||||
|
||||
// Check if player exists
|
||||
if(player == null) {
|
||||
writeStatusCode(outputStream, 1); // Unauthorized
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player doesn't already have a Pokémon tucked in
|
||||
if(player.getStatus() != PlayerStatus.AWAKE) {
|
||||
logger.warn("Player {} tried to upload save data while already asleep", player.getGameSyncId());
|
||||
|
||||
// Return error if not allowed
|
||||
if(!configuration.allowOverwritingPlayerDreamInfo()) {
|
||||
writeStatusCode(outputStream, 4); // Already dreaming
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update and save player information
|
||||
player.setStatus(PlayerStatus.SLEEPING);
|
||||
player.setDreamerInfo(info);
|
||||
|
||||
if(!playerManager.savePlayer(player)) {
|
||||
logger.warn("Save data failure for player {}", player.getGameSyncId());
|
||||
ctx.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send status code
|
||||
writeStatusCode(outputStream, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST handler for {@code /dsio/gw?p=account.create.upload}
|
||||
*/
|
||||
private void handleCreateAccount(PglRequest request, Context ctx) throws IOException {
|
||||
// It sends the entire save file, but we just skip through it because we don't need anything from it here
|
||||
ServletInputStream inputStream = ctx.req().getInputStream();
|
||||
|
||||
while(!inputStream.isFinished()) {
|
||||
inputStream.read();
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
LEOutputStream outputStream = new LEOutputStream(ctx.outputStream());
|
||||
String gameSyncId = request.gameSyncId();
|
||||
|
||||
// Check if player doesn't exist already
|
||||
if(playerManager.doesPlayerExist(gameSyncId)) {
|
||||
writeStatusCode(outputStream, 2); // Duplicate Game Sync ID
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to register player
|
||||
if(playerManager.registerPlayer(gameSyncId, request.gameVersion()) == null) {
|
||||
writeStatusCode(outputStream, 3); // Registration error
|
||||
return;
|
||||
}
|
||||
|
||||
// Write status code
|
||||
writeStatusCode(outputStream, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the 4-byte status code and 124 empty bytes to the output stream.
|
||||
*/
|
||||
private void writeStatusCode(LEOutputStream outputStream, int status) throws IOException {
|
||||
outputStream.writeInt(status);
|
||||
outputStream.writeBytes(0, 124);
|
||||
}
|
||||
}
|
||||
20
src/main/java/entralinked/network/http/pgl/PglRequest.java
Normal file
20
src/main/java/entralinked/network/http/pgl/PglRequest.java
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package entralinked.network.http.pgl;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
import entralinked.GameVersion;
|
||||
import entralinked.serialization.GsidDeserializer;
|
||||
|
||||
public record PglRequest(
|
||||
@JsonProperty(value = "gsid", required = true) @JsonDeserialize(using = GsidDeserializer.class) String gameSyncId,
|
||||
@JsonProperty(value = "p", required = true) String type,
|
||||
@JsonProperty(value = "rom", required = true) int romCode,
|
||||
@JsonProperty(value = "langcode", required = true) int languageCode,
|
||||
@JsonProperty(value = "dreamw", required = true) int dreamWorld, // Always 1, but what is it for?
|
||||
@JsonProperty(value = "tok", required = true) String token) {
|
||||
|
||||
public GameVersion gameVersion() {
|
||||
return GameVersion.lookup(romCode(), languageCode());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.CharArrayReader;
|
||||
import java.io.DataInput;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.io.IOContext;
|
||||
|
||||
public class GameSpyMessageFactory extends JsonFactory {
|
||||
|
||||
private static final long serialVersionUID = -8313019676052328025L;
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageGenerator _createGenerator(Writer writer, IOContext context) throws IOException {
|
||||
return new GameSpyMessageGenerator(_generatorFeatures, _objectCodec, writer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageGenerator _createUTF8Generator(OutputStream outputStream, IOContext context) throws IOException {
|
||||
return _createGenerator(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageParser _createParser(InputStream inputStream, IOContext context) throws IOException {
|
||||
return new GameSpyMessageParser(_parserFeatures, new InputStreamReader(inputStream));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageParser _createParser(byte[] data, int offset, int length, IOContext context) throws IOException, JsonParseException {
|
||||
return new GameSpyMessageParser(_parserFeatures, new InputStreamReader(new ByteArrayInputStream(data, offset, length)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageParser _createParser(char[] data, int offset, int length, IOContext context, boolean recyclable) throws IOException {
|
||||
return new GameSpyMessageParser(_parserFeatures, new CharArrayReader(data, offset, length));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GameSpyMessageParser _createParser(DataInput input, IOContext context) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
|
||||
import com.fasterxml.jackson.core.ObjectCodec;
|
||||
|
||||
public class GameSpyMessageGenerator extends SimpleGeneratorBase {
|
||||
|
||||
protected final Writer writer;
|
||||
|
||||
public GameSpyMessageGenerator(int features, ObjectCodec codec, Writer writer) {
|
||||
super(features, codec);
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeEndObject() throws IOException {
|
||||
_writeContext = _writeContext.getParent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeFieldName(String name) throws IOException {
|
||||
writer.write('\\');
|
||||
writer.write(name);
|
||||
writer.write('\\');
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeString(String text) throws IOException {
|
||||
writer.write(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
writer.close();
|
||||
} finally {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
|
||||
public class GameSpyMessageParser extends SimpleParserBase {
|
||||
|
||||
private final Reader reader;
|
||||
private String parsedString;
|
||||
private boolean endOfInput;
|
||||
private boolean closed;
|
||||
|
||||
public GameSpyMessageParser(int features, Reader reader) {
|
||||
super(features);
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonToken nextToken() throws IOException {
|
||||
if(endOfInput) {
|
||||
return _currToken = null; // Return null if there is no content left to read
|
||||
}
|
||||
|
||||
if(_currToken == null) {
|
||||
// TODO
|
||||
skipBackSlash();
|
||||
}
|
||||
|
||||
int i = parseString();
|
||||
|
||||
if(_currToken == JsonToken.VALUE_STRING || _currToken == null) {
|
||||
// Parse field name
|
||||
_currToken = JsonToken.FIELD_NAME;
|
||||
|
||||
if(parsedString.isEmpty()) {
|
||||
_reportUnexpectedChar(i, "expected field name");
|
||||
}
|
||||
|
||||
context.setCurrentName(parsedString);
|
||||
|
||||
if(i != '\\') {
|
||||
_reportUnexpectedChar(i, "expected '\\' to close field name");
|
||||
}
|
||||
|
||||
} else if(_currToken == JsonToken.FIELD_NAME) {
|
||||
// Parse value string
|
||||
_currToken = JsonToken.VALUE_STRING;
|
||||
context.setCurrentValue(parsedString);
|
||||
|
||||
if(i != '\\') {
|
||||
if(i != -1) {
|
||||
_reportUnexpectedChar(i, "expected '\\' to open field name");
|
||||
}
|
||||
|
||||
endOfInput = true; // We have reached the end, so let it be known!
|
||||
}
|
||||
}
|
||||
|
||||
return _currToken;
|
||||
}
|
||||
|
||||
private int skipBackSlash() throws IOException {
|
||||
int i = reader.read();
|
||||
|
||||
if(i != '\\') {
|
||||
_reportUnexpectedChar(i, "expected '\\' to open field name");
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
private int parseString() throws IOException {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
int i = -1;
|
||||
|
||||
while((i = reader.read()) != -1) {
|
||||
if(i == '\\') {
|
||||
break;
|
||||
}
|
||||
|
||||
builder.append((char)i);
|
||||
}
|
||||
|
||||
parsedString = builder.toString();
|
||||
return i;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if(!closed) {
|
||||
try {
|
||||
reader.close();
|
||||
} finally {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
||||
|
||||
import entralinked.utility.GsidUtility;
|
||||
|
||||
/**
|
||||
* Deserializer that stringifies integers using {@link GsidUtility}
|
||||
*/
|
||||
public class GsidDeserializer extends StdDeserializer<String> {
|
||||
|
||||
private static final long serialVersionUID = -2973925169701434892L;
|
||||
|
||||
public GsidDeserializer() {
|
||||
this(String.class);
|
||||
}
|
||||
|
||||
protected GsidDeserializer(Class<?> type) {
|
||||
super(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
int gsid = parser.getIntValue();
|
||||
|
||||
if(gsid < 0) {
|
||||
throw new IOException("Game Sync ID cannot be a negative number.");
|
||||
}
|
||||
|
||||
return GsidUtility.stringifyGameSyncId(gsid);
|
||||
}
|
||||
}
|
||||
131
src/main/java/entralinked/serialization/SimpleGeneratorBase.java
Normal file
131
src/main/java/entralinked/serialization/SimpleGeneratorBase.java
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import com.fasterxml.jackson.core.Base64Variant;
|
||||
import com.fasterxml.jackson.core.ObjectCodec;
|
||||
import com.fasterxml.jackson.core.base.GeneratorBase;
|
||||
|
||||
/**
|
||||
* Generator base for lazy implementations that simply write everything as strings.
|
||||
* Keeps subclasses clean.
|
||||
*/
|
||||
public abstract class SimpleGeneratorBase extends GeneratorBase {
|
||||
|
||||
protected SimpleGeneratorBase(int features, ObjectCodec codec) {
|
||||
super(features, codec);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void _releaseBuffers() {}
|
||||
|
||||
@Override
|
||||
protected void _verifyValueWrite(String typeMsg) throws IOException {}
|
||||
|
||||
@Override
|
||||
public void writeStartArray() throws IOException {
|
||||
throw new UnsupportedOperationException("this format does not support arrays");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeEndArray() throws IOException {
|
||||
throw new UnsupportedOperationException("this format does not support arrays");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeStartObject() throws IOException {
|
||||
if(!_writeContext.inRoot()) {
|
||||
throw new UnsupportedOperationException("this format does not support nested objects");
|
||||
}
|
||||
|
||||
// Quirk
|
||||
_writeContext = _writeContext.createChildObjectContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeString(char[] buffer, int offset, int length) throws IOException {
|
||||
writeString(new String(buffer, offset, length));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeRawUTF8String(byte[] buffer, int offset, int length) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeUTF8String(byte[] buffer, int offset, int length) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeRaw(String text) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeRaw(String text, int offset, int len) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeRaw(char[] text, int offset, int length) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeRaw(char c) throws IOException {
|
||||
writeString(String.valueOf(c));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeBinary(Base64Variant variant, byte[] data, int offset, int length) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(int value) throws IOException {
|
||||
writeString(String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(long value) throws IOException {
|
||||
writeString(String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(BigInteger value) throws IOException {
|
||||
writeString(value.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(double value) throws IOException {
|
||||
writeString(String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(float value) throws IOException {
|
||||
writeString(String.valueOf(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(BigDecimal value) throws IOException {
|
||||
writeString(value.toPlainString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNumber(String encodedValue) throws IOException {
|
||||
writeString(encodedValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeBoolean(boolean state) throws IOException {
|
||||
writeString(String.valueOf(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeNull() throws IOException {
|
||||
writeString("null");
|
||||
}
|
||||
}
|
||||
158
src/main/java/entralinked/serialization/SimpleParserBase.java
Normal file
158
src/main/java/entralinked/serialization/SimpleParserBase.java
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import com.fasterxml.jackson.core.Base64Variant;
|
||||
import com.fasterxml.jackson.core.JsonLocation;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.JsonStreamContext;
|
||||
import com.fasterxml.jackson.core.ObjectCodec;
|
||||
import com.fasterxml.jackson.core.Version;
|
||||
import com.fasterxml.jackson.core.base.ParserMinimalBase;
|
||||
import com.fasterxml.jackson.core.json.DupDetector;
|
||||
import com.fasterxml.jackson.core.json.JsonReadContext;
|
||||
|
||||
/**
|
||||
* Parser base for lazy implementations that simply parse everything as {@code VALUE_STRING}.
|
||||
* Keeps subclasses clean.
|
||||
*/
|
||||
public abstract class SimpleParserBase extends ParserMinimalBase {
|
||||
|
||||
protected JsonReadContext context;
|
||||
protected ObjectCodec codec;
|
||||
|
||||
public SimpleParserBase(int features) {
|
||||
super(features);
|
||||
DupDetector detector = isEnabled(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) ? DupDetector.rootDetector(this) : null;
|
||||
this.context = JsonReadContext.createRootContext(detector);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void _handleEOF() throws JsonParseException {}
|
||||
|
||||
@Override
|
||||
public Number getNumberValue() throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NumberType getNumberType() throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntValue() throws IOException {
|
||||
return Integer.parseInt(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLongValue() throws IOException {
|
||||
return Long.parseLong(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigInteger getBigIntegerValue() throws IOException {
|
||||
return new BigInteger(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloatValue() throws IOException {
|
||||
return Float.parseFloat(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getDoubleValue() throws IOException {
|
||||
return Double.parseDouble(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal getDecimalValue() throws IOException {
|
||||
return new BigDecimal(getStringValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBinaryValue(Base64Variant variant) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonStreamContext getParsingContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void overrideCurrentName(String name) {
|
||||
try {
|
||||
context.setCurrentName(name);
|
||||
} catch(JsonProcessingException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentName() throws IOException {
|
||||
return context.getCurrentName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText() throws IOException {
|
||||
switch(_currToken) {
|
||||
case FIELD_NAME: return context.getCurrentName();
|
||||
case VALUE_STRING: return context.getCurrentValue().toString();
|
||||
default: throw new IllegalStateException(); // Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public char[] getTextCharacters() throws IOException {
|
||||
return getText().toCharArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasTextCharacters() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTextLength() throws IOException {
|
||||
return getText().length();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTextOffset() throws IOException {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCodec(ObjectCodec codec) {
|
||||
this.codec = codec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectCodec getCodec() {
|
||||
return codec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonLocation getCurrentLocation() {
|
||||
return JsonLocation.NA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonLocation getTokenLocation() {
|
||||
return JsonLocation.NA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Version version() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getStringValue() {
|
||||
return getCurrentValue() == null ? "null" : getCurrentValue().toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.CharArrayReader;
|
||||
import java.io.DataInput;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.io.IOContext;
|
||||
|
||||
public class UrlEncodedFormFactory extends JsonFactory {
|
||||
|
||||
private static final long serialVersionUID = -8313019676052328025L;
|
||||
public static final int DEFAULT_FORMAT_GENERATOR_FEATURES = UrlEncodedFormGenerator.Feature.getDefaults();
|
||||
public static final int DEFAULT_FORMAT_PARSER_FEATURES = UrlEncodedFormParser.Feature.getDefaults();
|
||||
protected int formatGeneratorFeatures = DEFAULT_FORMAT_GENERATOR_FEATURES;
|
||||
protected int formatParserFeatures = DEFAULT_FORMAT_PARSER_FEATURES;
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormGenerator _createGenerator(Writer writer, IOContext context) throws IOException {
|
||||
return new UrlEncodedFormGenerator(_generatorFeatures, formatGeneratorFeatures, _objectCodec, writer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormGenerator _createUTF8Generator(OutputStream outputStream, IOContext context) throws IOException {
|
||||
return _createGenerator(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormParser _createParser(InputStream inputStream, IOContext context) throws IOException {
|
||||
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new InputStreamReader(inputStream));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormParser _createParser(byte[] data, int offset, int length, IOContext context) throws IOException, JsonParseException {
|
||||
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new InputStreamReader(new ByteArrayInputStream(data, offset, length)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormParser _createParser(char[] data, int offset, int length, IOContext context, boolean recyclable) throws IOException {
|
||||
return new UrlEncodedFormParser(_parserFeatures, formatParserFeatures, new CharArrayReader(data, offset, length));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UrlEncodedFormParser _createParser(DataInput input, IOContext context) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getFormatGeneratorFeatures() {
|
||||
return formatGeneratorFeatures;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getFormatParserFeatures() {
|
||||
return formatParserFeatures;
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory configure(UrlEncodedFormGenerator.Feature feature, boolean state) {
|
||||
return state ? enable(feature) : disable(feature);
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory enable(UrlEncodedFormGenerator.Feature feature) {
|
||||
formatGeneratorFeatures |= feature.getMask();
|
||||
return this;
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory disable(UrlEncodedFormGenerator.Feature feature) {
|
||||
formatGeneratorFeatures &= ~feature.getMask();
|
||||
return this;
|
||||
}
|
||||
|
||||
public final boolean isEnabled(UrlEncodedFormGenerator.Feature feature) {
|
||||
return feature.enabledIn(formatGeneratorFeatures);
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory configure(UrlEncodedFormParser.Feature feature, boolean state) {
|
||||
return state ? enable(feature) : disable(feature);
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory enable(UrlEncodedFormParser.Feature feature) {
|
||||
formatParserFeatures |= feature.getMask();
|
||||
return this;
|
||||
}
|
||||
|
||||
public UrlEncodedFormFactory disable(UrlEncodedFormParser.Feature feature) {
|
||||
formatParserFeatures &= ~feature.getMask();
|
||||
return this;
|
||||
}
|
||||
|
||||
public final boolean isEnabled(UrlEncodedFormParser.Feature feature) {
|
||||
return feature.enabledIn(formatParserFeatures);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import com.fasterxml.jackson.core.FormatFeature;
|
||||
import com.fasterxml.jackson.core.ObjectCodec;
|
||||
import com.fasterxml.jackson.core.json.JsonWriteContext;
|
||||
|
||||
/**
|
||||
* Generator for URL encoded forms.
|
||||
* I don't doubt its imperfection, but it does what it needs to do.
|
||||
*/
|
||||
public class UrlEncodedFormGenerator extends SimpleGeneratorBase {
|
||||
|
||||
/**
|
||||
* Generator format features for form-data
|
||||
*/
|
||||
public enum Feature implements FormatFeature {
|
||||
|
||||
/**
|
||||
* Whether values should be encoded as base64
|
||||
*/
|
||||
BASE64_ENCODE_VALUES(true);
|
||||
|
||||
private final boolean defaultState;
|
||||
private final int mask;
|
||||
|
||||
private Feature(boolean defaultState) {
|
||||
this.defaultState = defaultState;
|
||||
this.mask = 1 << ordinal();
|
||||
}
|
||||
|
||||
public static int getDefaults() {
|
||||
int flags = 0;
|
||||
|
||||
for(Feature feature : values()) {
|
||||
if(feature.enabledByDefault()) {
|
||||
flags |= feature.getMask();
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabledByDefault() {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMask() {
|
||||
return mask;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabledIn(int flags) {
|
||||
return (flags & mask) != 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected final int formatFeatures;
|
||||
protected final Writer writer;
|
||||
|
||||
protected UrlEncodedFormGenerator(int features, int formatFeatures, ObjectCodec codec, Writer writer) {
|
||||
super(features, codec);
|
||||
this.formatFeatures = formatFeatures;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeEndObject() throws IOException {
|
||||
_writeContext = _writeContext.getParent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeFieldName(String name) throws IOException {
|
||||
int status = _writeContext.writeFieldName(name);
|
||||
|
||||
// Check if an entry separator should be appended before the field name
|
||||
if(status == JsonWriteContext.STATUS_OK_AFTER_COMMA) {
|
||||
writer.write('&');
|
||||
}
|
||||
|
||||
writer.write(URLEncoder.encode(name, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeString(String text) throws IOException {
|
||||
int status = _writeContext.writeValue();
|
||||
|
||||
// Check if a key/value separator should be appended before the string value (should always be the case)
|
||||
if(status == JsonWriteContext.STATUS_OK_AFTER_COLON) {
|
||||
writer.write('=');
|
||||
}
|
||||
|
||||
String value = text;
|
||||
|
||||
// Encode value as base64 if feature is enabled
|
||||
if(Feature.BASE64_ENCODE_VALUES.enabledIn(formatFeatures)) {
|
||||
value = Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.ISO_8859_1))
|
||||
.replace('=', '*').replace('+', '.').replace('/', '-');
|
||||
}
|
||||
|
||||
writer.write(URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getFormatFeatures() {
|
||||
return formatFeatures;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
writer.close();
|
||||
} finally {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package entralinked.serialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import com.fasterxml.jackson.core.FormatFeature;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
|
||||
/**
|
||||
* Parser for URL encoded forms.
|
||||
* It's not perfect, but it works well enough for the intended purpose.
|
||||
*/
|
||||
public class UrlEncodedFormParser extends SimpleParserBase {
|
||||
|
||||
/**
|
||||
* Parser format features for form-data
|
||||
*/
|
||||
public enum Feature implements FormatFeature {
|
||||
|
||||
/**
|
||||
* Whether values are encoded in base64 and should be decoded as such
|
||||
*/
|
||||
BASE64_DECODE_VALUES(true);
|
||||
|
||||
private final boolean defaultState;
|
||||
private final int mask;
|
||||
|
||||
private Feature(boolean defaultState) {
|
||||
this.defaultState = defaultState;
|
||||
this.mask = 1 << ordinal();
|
||||
}
|
||||
|
||||
public static int getDefaults() {
|
||||
int flags = 0;
|
||||
|
||||
for(Feature feature : values()) {
|
||||
if(feature.enabledByDefault()) {
|
||||
flags |= feature.getMask();
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabledByDefault() {
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMask() {
|
||||
return mask;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enabledIn(int flags) {
|
||||
return (flags & mask) != 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final Reader reader;
|
||||
private final int formatFeatures;
|
||||
private String parsedString;
|
||||
private boolean closed;
|
||||
private boolean endOfInput;
|
||||
|
||||
public UrlEncodedFormParser(int features, int formatFeatures, Reader reader) {
|
||||
super(features);
|
||||
this.formatFeatures = formatFeatures;
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonToken nextToken() throws IOException {
|
||||
if(endOfInput) {
|
||||
return _currToken = null; // Return null if there is no content left to read
|
||||
}
|
||||
|
||||
int i = parseStringAndSkipSeparator();
|
||||
|
||||
if(_currToken == JsonToken.VALUE_STRING || _currToken == null) {
|
||||
// Parse field name
|
||||
_currToken = JsonToken.FIELD_NAME;
|
||||
|
||||
if(parsedString.isEmpty()) {
|
||||
_reportUnexpectedChar(i, "expected field name");
|
||||
}
|
||||
|
||||
context.setCurrentName(URLDecoder.decode(parsedString, StandardCharsets.UTF_8));
|
||||
|
||||
if(i != '=') {
|
||||
_reportUnexpectedChar(i, "expected '=' to mark end of key and start of value");
|
||||
}
|
||||
|
||||
} else if(_currToken == JsonToken.FIELD_NAME) {
|
||||
// Parse value string
|
||||
_currToken = JsonToken.VALUE_STRING;
|
||||
|
||||
// Decode base64 if feature is enabled
|
||||
if(Feature.BASE64_DECODE_VALUES.enabledIn(formatFeatures)) {
|
||||
parsedString = new String(Base64.getDecoder().decode(
|
||||
parsedString.replace('*', '=').replace('.', '+').replace('-', '/')), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
context.setCurrentValue(URLDecoder.decode(parsedString, StandardCharsets.UTF_8));
|
||||
|
||||
if(i != '&') {
|
||||
if(i != -1) {
|
||||
_reportUnexpectedChar(i, "expected '&' to mark end of value and start of new key");
|
||||
}
|
||||
|
||||
endOfInput = true; // We have reached the end, so let it be known!
|
||||
}
|
||||
}
|
||||
|
||||
return _currToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the next string and stores the output in {@link #parsedString}.
|
||||
* The returned value is the first character read after the parsed string, or -1 if end-of-input.
|
||||
*/
|
||||
private int parseStringAndSkipSeparator() throws IOException {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
int i = -1;
|
||||
|
||||
while((i = reader.read()) != -1) {
|
||||
if(i == '&' || i == '=') {
|
||||
break;
|
||||
}
|
||||
|
||||
builder.append((char)i);
|
||||
}
|
||||
|
||||
parsedString = builder.toString();
|
||||
return i;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if(!closed) {
|
||||
try {
|
||||
reader.close();
|
||||
} finally {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getFormatFeatures() {
|
||||
return formatFeatures;
|
||||
}
|
||||
}
|
||||
157
src/main/java/entralinked/utility/CertificateGenerator.java
Normal file
157
src/main/java/entralinked/utility/CertificateGenerator.java
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Calendar;
|
||||
import java.util.zip.DataFormatException;
|
||||
import java.util.zip.Inflater;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
|
||||
/**
|
||||
* Utility class for generating SSL certificates that are trusted by the DS.
|
||||
*/
|
||||
public class CertificateGenerator {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static final String issuerCertificateString =
|
||||
"eNp9lNuyqjgQhu95irm3dokoHi4TEk4aNBBAuAPRIOBZCPL0G5e1Zq+ZqZpcdqr/Tvf/dX796g/EhuX8pWGXWbqlAYbfwV8SsSw0ZZoGRksOhAUBt6BRAAfy8paXR2"
|
||||
+ "MhZAiorwOkwQLhFQGlAUY+hjnRgoC0Eu6AC7kT9JlMq7J8p+TX9JTJqTJpLQT2n7sL02X1mp7dKj25jWUsThbWn/HWvkqRgrkXqgWhQmg8QgGlKyycNZNxqyPgfQ"
|
||||
+ "QI034KYAcSzxIWiGxpeYmtvNk5gJZQz50i8mGdKrhOT1WdGUEdG/M6UhZPAidbxIBC0K4lBVZIR7t1cNlKiJF3UDjo76DgMW21Dtif6hEDVcCISwSmXy+0sLhuo6"
|
||||
+ "1zSUL1LGXGoiYuEOjzfBOLzItD95kqakk8LEzxFV/iNl/3CXIcqmX6gk18hDANg5eUhE5u4aqOXpNW70Dw3TOqsmviwXUgE+6bdhNroP3u+2fb0v/2bRkHAmRD82"
|
||||
+ "6GZ6VjRPHbUgAmhvO29UiXUOIUxaq8cRfEcrHijeaFOyPquizn6UQE7fi1TXYvmBeHyPM2bmovzVXhogl7ni+zk51OJPNg7LEiKy5bdA8gBlNTni6aod9MydZdDT"
|
||||
+ "S+3AB9tqvjKfNJ4pbyKps14fO4qZMiDqJaKumJE81SqqOjWc0Qbs8g2bvuphuMcwzY4Kjfh+tNFWwYXy9ojxYF8DKxIOt6ev2HkLS3Na7MILAEQOABecXzksO8yT"
|
||||
+ "VAZWy2UcbGsEorhzENmr0L1e6k925QTpXehcyoTm8nMsMX5kdsDWGE9dWRdObZEVSFNwXe6krfkNnwah6TrB+u+nbL9AiWeIoFT6ZAN59bv5u3dzNr54fRUuUL9H"
|
||||
+ "CCgMZD7xIZ3tUyNn0iIfQhtA9QBhZ2IPkdZqTf1PeGaZAsA0XvEqOq47HbpAXmPcKfu5xs2R+beapEnIajStqdqiLyoJ2eSIsQWH5AejAgqxvKsE+g9SXQY7T6Kd"
|
||||
+ "CPp7UKwKWfe69RHGLFyamhNpGn1n/QVYtUUfqhAuF85nSA2kSsELhJF+3I7UtUJqYr79ClWSnOK9X+nSw3/6B0bD++CZW+EYUfRCEwwmJiGDf3tDvYLpqfj+6Tne"
|
||||
+ "Fw2ewOxmIyw3d4HFGrLx9yvk6Q5HpTlSgvbgO22k/vo0N43dO7utgfn/e4Xo9alIAktnewGqj5nRm75H7iaLEJBo983jJdugeXdp6y5X6sDmaDmyw/p91TKPrKmD"
|
||||
+ "4ebbTY38D5WT67fONjW/X1OLNHg46GYYZa6etbxQ7671f7GypgwnI=";
|
||||
|
||||
|
||||
private static final String issuerPrivateKeyString =
|
||||
"eNod0Em2a0AAANAFGRBNYVgK0QalfWaIPproZfX/nH+XcG1dR4mnSxCaUu3JKUe5WLR1rND+Q+gwb3NO3ws5e0YXcydZcUtNV/35votzw9SsDstssI0TPxg5q1XPUq"
|
||||
+ "EpGgfib4UnATQKiAcZHsBOsEWg2nShyhd7CoLQznBPWW/+iLfW3bMujf723htqG+n0p30h/SClZIRZibH7I5hGgQHRqgvpuJ/IDWpH9HQZelCC0xPK8uXZ87jcwv"
|
||||
+ "Al64ufS7EqDw4HZfb6fi81sZvq02Xx84aBxLRvYg+ASqvaLsqPb9CrjrxW0koV02lUS9bWCSuQctd+t4w/3BwIjy0ZUdeOqEKzl95s4Sgcw66TQ55RWT5f78KalH"
|
||||
+ "ncOiUv2Gk9VoV8tHw7sYC9RsH3m2xH6QeAFirI8+Qff3Lmmgrp3rqZbymH8skaH7Cku2uPPCe0xn2oP3uM7pOqtbvUKFyUyZ9tKqj7StW5zJ2UgvpYMeuc7INHAo"
|
||||
+ "hV2CuQM0H0TU3f/wzd42bWlkx50Fc5eWTmBGCsMe95I05TreXUNFNLiisySELsXkZC8JNhR3KaLsmrWI4fa6iNpzrP1TOg/6StsTjERretEDEB7gZ28+mhQAs7T6"
|
||||
+ "MzgI1W5a+hUvdFVGB43qZr5eOJ07g/FVIZ7xCYsGlcooISBawqUerOMyT4ivKwCC8XMn0wDNXSVb/+FtVXuzmmoQKx6C2OiW921ctlpI7+L7QPxl9RWjM8QsdL54"
|
||||
+ "4sSHNmIW+3mVEWW8H9+3/s59kaKT97p4oFm+9Jc1hKrMu5/lhDrSe0QqBnwRnZx0jKRqdxHG7Z6lflnu0SfxCFKIwe2laBvNJzVHPlTayy/g/JGQy0";
|
||||
|
||||
private static X509Certificate issuerCertificate;
|
||||
private static PrivateKey issuerPrivateKey;
|
||||
private static boolean initialized;
|
||||
|
||||
public static void initialize() {
|
||||
if(initialized) {
|
||||
logger.warn("CertificateGenerator is already initialized!");
|
||||
return;
|
||||
}
|
||||
|
||||
System.setProperty("jdk.tls.useExtendedMasterSecret", "false"); // Will straight up not reuse SSL sessions if this is enabled
|
||||
Security.setProperty("jdk.tls.disabledAlgorithms", "");
|
||||
Security.setProperty("jdk.tls.legacyAlgorithms", "");
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 1);
|
||||
|
||||
try {
|
||||
issuerCertificate = (X509Certificate)CertificateFactory.getInstance("X.509").generateCertificate(
|
||||
new ByteArrayInputStream(decodeAndDeflate(issuerCertificateString)));
|
||||
} catch (CertificateException | DataFormatException e) {
|
||||
logger.error("Could not create issuer certificate", e);
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(decodeAndDeflate(issuerPrivateKeyString));
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
issuerPrivateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException | DataFormatException e) {
|
||||
logger.error("Could not create issuer private key", e);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public static KeyStore generateCertificateKeyStore(String type, String password) throws GeneralSecurityException, IOException {
|
||||
// Generate subject keys
|
||||
KeyPairGenerator keyGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyGenerator.initialize(1024);
|
||||
KeyPair keyPair = keyGenerator.generateKeyPair();
|
||||
SubjectPublicKeyInfo publicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded());
|
||||
|
||||
// Generate start/end dates
|
||||
Calendar notBefore = Calendar.getInstance();
|
||||
Calendar notAfter = Calendar.getInstance();
|
||||
notAfter.add(Calendar.YEAR, 50);
|
||||
|
||||
// Voodoo because PKCS12 will cry that the DN doesn't match if the attributes aren't in the same order
|
||||
X500Name issuerName = new JcaX509CertificateHolder(issuerCertificate).getSubject();
|
||||
|
||||
// Configure certificate builder
|
||||
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(
|
||||
issuerName,
|
||||
BigInteger.ONE,
|
||||
notBefore.getTime(),
|
||||
notAfter.getTime(),
|
||||
new X500Name("CN=*.*.*"),
|
||||
publicKeyInfo)
|
||||
.addExtension(Extension.authorityKeyIdentifier, false, AuthorityKeyIdentifier.getInstance(
|
||||
JcaX509ExtensionUtils.parseExtensionValue(issuerCertificate.getExtensionValue(Extension.authorityKeyIdentifier.getId()))));
|
||||
|
||||
// Sign certificate and create chain
|
||||
ContentSigner signer = null;
|
||||
|
||||
try {
|
||||
signer = new JcaContentSignerBuilder("SHA1withRSA").build(issuerPrivateKey);
|
||||
} catch(OperatorCreationException e) {
|
||||
// Delegate exception
|
||||
throw new GeneralSecurityException(e);
|
||||
}
|
||||
|
||||
X509Certificate subjectCertificate = new JcaX509CertificateConverter().getCertificate(builder.build(signer));
|
||||
X509Certificate[] certificateChain = { subjectCertificate, issuerCertificate };
|
||||
|
||||
// And finally, create the keystore
|
||||
KeyStore keyStore = KeyStore.getInstance(type);
|
||||
keyStore.load(null, null);
|
||||
keyStore.setCertificateEntry("server", subjectCertificate);
|
||||
keyStore.setKeyEntry("server", keyPair.getPrivate(), password == null ? null : password.toCharArray(), certificateChain);
|
||||
return keyStore;
|
||||
}
|
||||
|
||||
private static byte[] decodeAndDeflate(String input) throws DataFormatException {
|
||||
byte[] bytes = Base64.getDecoder().decode(input.getBytes());
|
||||
Inflater inflater = new Inflater();
|
||||
inflater.setInput(bytes);
|
||||
byte[] buffer = new byte[2048];
|
||||
byte[] output = new byte[inflater.inflate(buffer)];
|
||||
System.arraycopy(buffer, 0, output, 0, output.length);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
61
src/main/java/entralinked/utility/ConsumerAppender.java
Normal file
61
src/main/java/entralinked/utility/ConsumerAppender.java
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.logging.log4j.core.Appender;
|
||||
import org.apache.logging.log4j.core.Core;
|
||||
import org.apache.logging.log4j.core.Filter;
|
||||
import org.apache.logging.log4j.core.Layout;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Property;
|
||||
import org.apache.logging.log4j.core.config.plugins.Plugin;
|
||||
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
|
||||
import org.apache.logging.log4j.core.config.plugins.PluginElement;
|
||||
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
|
||||
|
||||
@Plugin(name = "ConsumerAppender",
|
||||
category = Core.CATEGORY_NAME,
|
||||
elementType = Appender.ELEMENT_TYPE,
|
||||
printObject = true)
|
||||
public class ConsumerAppender extends AbstractAppender {
|
||||
|
||||
protected static final Map<String, List<Consumer<String>>> consumerMap = new ConcurrentHashMap<>();
|
||||
|
||||
protected ConsumerAppender(String name, Filter filter, Layout<? extends Serializable> layout,
|
||||
boolean ignoreExceptions, Property[] properties) {
|
||||
super(name, filter, layout, ignoreExceptions, properties);
|
||||
}
|
||||
|
||||
@PluginFactory
|
||||
public static ConsumerAppender createAppender(
|
||||
@PluginAttribute("name") String name,
|
||||
@PluginElement("filter") Filter filter,
|
||||
@PluginElement("Layout") Layout<? extends Serializable> layout,
|
||||
@PluginAttribute("ignoreExceptions") boolean ignoreExceptions) {
|
||||
return new ConsumerAppender(name, filter, layout, ignoreExceptions, null);
|
||||
}
|
||||
|
||||
public static void addConsumer(String appenderName, Consumer<String> consumer) {
|
||||
List<Consumer<String>> consumers = consumerMap.getOrDefault(appenderName, new ArrayList<>());
|
||||
consumers.add(consumer);
|
||||
consumerMap.putIfAbsent(appenderName, consumers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void append(LogEvent event) {
|
||||
String formattedMessage = getLayout().toSerializable(event).toString();
|
||||
List<Consumer<String>> consumers = consumerMap.get(getName());
|
||||
|
||||
if(consumers != null) {
|
||||
for(Consumer<String> consumer : consumers) {
|
||||
consumer.accept(formattedMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/main/java/entralinked/utility/Crc16.java
Normal file
38
src/main/java/entralinked/utility/Crc16.java
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package entralinked.utility;
|
||||
|
||||
/**
|
||||
* Utility class for calculating CRC-16 checksums.
|
||||
*/
|
||||
public class Crc16 {
|
||||
|
||||
public static int calc(byte[] input, int offset, int length) {
|
||||
int crc = 0xFFFF;
|
||||
|
||||
for(int i = offset; i < offset + length; i++) {
|
||||
crc ^= (input[i] << 8);
|
||||
|
||||
for(int j = 0; j < 8; j++) {
|
||||
if((crc & 0x8000) != 0) {
|
||||
crc = crc << 1 ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
public static int calc(byte[] input) {
|
||||
return calc(input, 0, input.length);
|
||||
}
|
||||
|
||||
public static int calc(int input) {
|
||||
return calc(new byte[] {
|
||||
(byte)(input & 0xFF),
|
||||
(byte)((input >> 8) & 0xFF),
|
||||
(byte)((input >> 16) & 0xFF),
|
||||
(byte)((input >> 24) & 0xFF)
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/main/java/entralinked/utility/CredentialGenerator.java
Normal file
35
src/main/java/entralinked/utility/CredentialGenerator.java
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Simple utility class for generating client credentials.
|
||||
*/
|
||||
public class CredentialGenerator {
|
||||
|
||||
public static final String CHALLENGE_CHARTABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
/**
|
||||
* @return A securely-generated server challenge of the specified length.
|
||||
*/
|
||||
public static String generateChallenge(int length) {
|
||||
char[] challenge = new char[length];
|
||||
|
||||
for(int i = 0; i < challenge.length; i++) {
|
||||
challenge[i] = CHALLENGE_CHARTABLE.charAt(secureRandom.nextInt(CHALLENGE_CHARTABLE.length()));
|
||||
}
|
||||
|
||||
return new String(challenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A base64-encoded, securely-generated auth token of the specified length.
|
||||
*/
|
||||
public static String generateAuthToken(int length) {
|
||||
byte[] bytes = new byte[length];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
42
src/main/java/entralinked/utility/GsidUtility.java
Normal file
42
src/main/java/entralinked/utility/GsidUtility.java
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class GsidUtility {
|
||||
|
||||
public static final String GSID_CHARTABLE = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
public static final Pattern GSID_PATTERN = Pattern.compile("[A-HJ-NP-Z2-9]{10}");
|
||||
|
||||
/**
|
||||
* Stringifies the specified numerical Game Sync ID
|
||||
*
|
||||
* Black 2 - {@code sub_21B480C} (overlay #199)
|
||||
*/
|
||||
public static String stringifyGameSyncId(int gsid) {
|
||||
char[] output = new char[10];
|
||||
int index = 0;
|
||||
|
||||
// v12 = gsid
|
||||
// v5 = sub_204405C(gsid, 4u)
|
||||
// v8 = v5 + __CFSHR__(v12, 31) + (v12 >> 31)
|
||||
|
||||
// uses unsigned ints for bitshift operations
|
||||
long ugsid = gsid;
|
||||
long checksum = Crc16.calc(gsid); // + __CFSHR__(v12, 31) + (v12 >> 31); ??
|
||||
|
||||
// do while v4 < 10
|
||||
for(int i = 0; i < output.length; i++) {
|
||||
index = (int)((ugsid & 0x1F) & 0x1FFFF); // chartable string is unicode, so normally multiplies by 2
|
||||
ugsid = (ugsid >> 5) | (checksum << 27);
|
||||
checksum >>= 5;
|
||||
output[i] = GSID_CHARTABLE.charAt(index); // sub_2048734(v4, chartable + index)
|
||||
|
||||
}
|
||||
|
||||
return new String(output);
|
||||
}
|
||||
|
||||
public static boolean isValidGameSyncId(String gsid) {
|
||||
return GSID_PATTERN.matcher(gsid).matches();
|
||||
}
|
||||
}
|
||||
58
src/main/java/entralinked/utility/LEOutputStream.java
Normal file
58
src/main/java/entralinked/utility/LEOutputStream.java
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Like {@link DataOutputStream}, but for little endian.
|
||||
* It's comical how this is not in base Java yet.
|
||||
*/
|
||||
public class LEOutputStream extends FilterOutputStream {
|
||||
|
||||
protected final byte[] buffer = new byte[8];
|
||||
|
||||
public LEOutputStream(OutputStream outputStream) {
|
||||
super(outputStream);
|
||||
}
|
||||
|
||||
public void writeBytes(int value, int amount) throws IOException {
|
||||
for(int i = 0; i < amount; i++) {
|
||||
out.write(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void writeShort(int value) throws IOException {
|
||||
buffer[0] = (byte)(value & 0xFF);
|
||||
buffer[1] = (byte)((value >> 8) & 0xFF);
|
||||
out.write(buffer, 0, 2);
|
||||
}
|
||||
|
||||
public void writeInt(int value) throws IOException {
|
||||
buffer[0] = (byte)(value & 0xFF);
|
||||
buffer[1] = (byte)((value >> 8) & 0xFF);
|
||||
buffer[2] = (byte)((value >> 16) & 0xFF);
|
||||
buffer[3] = (byte)((value >> 24) & 0xFF);
|
||||
out.write(buffer, 0, 4);
|
||||
}
|
||||
|
||||
public void writeFloat(float value) throws IOException {
|
||||
writeInt(Float.floatToIntBits(value));
|
||||
}
|
||||
|
||||
public void writeLong(long value) throws IOException {
|
||||
buffer[0] = (byte)(value & 0xFF);
|
||||
buffer[1] = (byte)((value >> 8) & 0xFF);
|
||||
buffer[2] = (byte)((value >> 16) & 0xFF);
|
||||
buffer[3] = (byte)((value >> 24) & 0xFF);
|
||||
buffer[4] = (byte)((value >> 32) & 0xFF);
|
||||
buffer[5] = (byte)((value >> 40) & 0xFF);
|
||||
buffer[6] = (byte)((value >> 48) & 0xFF);
|
||||
buffer[7] = (byte)((value >> 56) & 0xFF);
|
||||
out.write(buffer, 0, 8);
|
||||
}
|
||||
|
||||
public void writeDouble(double value) throws IOException {
|
||||
writeLong(Double.doubleToLongBits(value));
|
||||
}
|
||||
}
|
||||
34
src/main/java/entralinked/utility/MD5.java
Normal file
34
src/main/java/entralinked/utility/MD5.java
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package entralinked.utility;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import io.netty.util.internal.StringUtil;
|
||||
|
||||
/**
|
||||
* Utility class for generating hex-formatted MD5 hashes.
|
||||
*/
|
||||
public class MD5 {
|
||||
|
||||
private static final Logger logger = LogManager.getLogger();
|
||||
private static MessageDigest digest;
|
||||
|
||||
/**
|
||||
* @return A hex-formatted MD5 hash of the specified input.
|
||||
*/
|
||||
public static String digest(String string) {
|
||||
if(digest == null) {
|
||||
try {
|
||||
digest = MessageDigest.getInstance("MD5");
|
||||
} catch(NoSuchAlgorithmException e) {
|
||||
logger.error("Could not get MD5 MessageDigest instance", e);
|
||||
}
|
||||
}
|
||||
|
||||
return StringUtil.toHexStringPadded(digest.digest(string.getBytes(StandardCharsets.ISO_8859_1)));
|
||||
}
|
||||
}
|
||||
14
src/main/resources/dashboard/login.html
Normal file
14
src/main/resources/dashboard/login.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="styles/login.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="root-container">
|
||||
<label for="gsid">Game Sync ID</label><br>
|
||||
<input type='text' id="gsid" name='gsid' placeholder='XXXXXXXXXX'>
|
||||
<button id="login" onclick="postLogin()">Login</button>
|
||||
</div>
|
||||
</body>
|
||||
<script src="scripts/login.js"></script>
|
||||
</html>
|
||||
173
src/main/resources/dashboard/profile.html
Normal file
173
src/main/resources/dashboard/profile.html
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="styles/profile.css">
|
||||
</head>
|
||||
<body onload="fetchProfileData()">
|
||||
<div id="main-container" class="root-container" style="display:none;">
|
||||
<div>
|
||||
<label id="game-summary" class="header-text"></label>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Dreamer Summary -->
|
||||
<label>Tucked-in Pokémon Summary</label><br>
|
||||
<table id="dreamer-summary" class="dreamer-summary">
|
||||
<tr>
|
||||
<td id="dreamer-sprite" class="dreamer-sprite" rowspan="5">
|
||||
<image src="/sprites/pokemon/normal/0.png"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Species</th>
|
||||
<td id="dreamer-species"></td>
|
||||
<th>Nature</th>
|
||||
<td id="dreamer-nature"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<td id="dreamer-name"></td>
|
||||
<th>Gender</th>
|
||||
<td id="dreamer-gender"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Trainer</th>
|
||||
<td id="dreamer-trainer"></td>
|
||||
<th>Level</th>
|
||||
<td id="dreamer-level"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Trainer ID</th>
|
||||
<td id="dreamer-trainer-id"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Entree Forest Encounter Configuration -->
|
||||
<label>Entree Forest Encounters (Max. 10)</label><br>
|
||||
<div>
|
||||
<table id="encounter-table" class="encounter-image-table">
|
||||
<tr>
|
||||
<td><a id="encounter0" href="#configureEncounter" onclick="configureEncounter(0)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter1" href="#configureEncounter" onclick="configureEncounter(1)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter2" href="#configureEncounter" onclick="configureEncounter(2)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter3" href="#configureEncounter" onclick="configureEncounter(3)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter4" href="#configureEncounter" onclick="configureEncounter(4)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a id="encounter5" href="#configureEncounter" onclick="configureEncounter(5)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter6" href="#configureEncounter" onclick="configureEncounter(6)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter7" href="#configureEncounter" onclick="configureEncounter(7)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter8" href="#configureEncounter" onclick="configureEncounter(8)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
<td><a id="encounter9" href="#configureEncounter" onclick="configureEncounter(9)"><image src="/sprites/pokemon/normal/0.png"/></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Item Configuration -->
|
||||
<label>Items (Max. 20)</label><br>
|
||||
<div>
|
||||
<table id="item-table" class="item-table">
|
||||
<tr>
|
||||
<td><a id="item0" href="#configureItem" onclick="configureItem(0)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item1" href="#configureItem" onclick="configureItem(1)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item2" href="#configureItem" onclick="configureItem(2)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item3" href="#configureItem" onclick="configureItem(3)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item4" href="#configureItem" onclick="configureItem(4)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item5" href="#configureItem" onclick="configureItem(5)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item6" href="#configureItem" onclick="configureItem(6)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item7" href="#configureItem" onclick="configureItem(7)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item8" href="#configureItem" onclick="configureItem(8)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item9" href="#configureItem" onclick="configureItem(9)"><image src="/sprites/items/0.png"/></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a id="item10" href="#configureItem" onclick="configureItem(10)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item11" href="#configureItem" onclick="configureItem(11)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item12" href="#configureItem" onclick="configureItem(12)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item13" href="#configureItem" onclick="configureItem(13)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item14" href="#configureItem" onclick="configureItem(14)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item15" href="#configureItem" onclick="configureItem(15)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item16" href="#configureItem" onclick="configureItem(16)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item17" href="#configureItem" onclick="configureItem(17)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item18" href="#configureItem" onclick="configureItem(18)"><image src="/sprites/items/0.png"/></a></td>
|
||||
<td><a id="item19" href="#configureItem" onclick="configureItem(19)"><image src="/sprites/items/0.png"/></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Misc Configurations -->
|
||||
<div class="grid-container">
|
||||
<div>
|
||||
<label>CGear Skin</label>
|
||||
<select id="cgear-skin">
|
||||
<option value="none">Do not change</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pokédex Skin</label>
|
||||
<select id="dex-skin">
|
||||
<option value="none">Do not change</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Musical</label>
|
||||
<select id="musical">
|
||||
<option value="none">Do not change</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Level Gain</label>
|
||||
<input id="level-gain-input" type="number" value="0" min="0" max="99"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="save" class="big-button" onclick="postProfileData()">Save Profile</button>
|
||||
<button id="logout" class="big-button" onclick="postLogout()">Log Out</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Entree Forest Encounter Configuration Form -->
|
||||
<div id="configureEncounter" class="popup">
|
||||
<div class="content">
|
||||
<button class="close-button" onclick="closeEncounterForm()">X</button>
|
||||
<form id="encounter-form">
|
||||
<label for="encounter-form-species">Species ID</label>
|
||||
<input id="encounter-form-species" name="species" type="number" value="1" min="1" max="493"/>
|
||||
<label for="encounter-form-move">Move ID</label>
|
||||
<input id="encounter-form-move" name="move" type="number" value="1" min="1" max="559"/>
|
||||
<label for="encounter-form-form">Forme Index</label>
|
||||
<input id="encounter-form-form" name="form" type="number" value="0" min="0" max="31"/>
|
||||
<label for="encounter-form-animation">Animation</label>
|
||||
<select id="encounter-form-animation" name="animation">
|
||||
<option value="LOOK_AROUND">Look Around</option>
|
||||
<option value="WALK_AROUND">Walk Around</option>
|
||||
<option value="WALK_LOOK_AROUND">Walk and Look Around</option>
|
||||
<option value="WALK_VERTICALLY">Walk Vertically</option>
|
||||
<option value="WALK_HORIZONTALLY">Walk Horizontally</option>
|
||||
<option value="WALK_HORIZONTALLY_LOOK_AROUND">Walk Horizontally and Look Around</option>
|
||||
<option value="SPIN_RIGHT">Spin Right</option>
|
||||
<option value="SPIN_LEFT">Spin Left</option>
|
||||
</select>
|
||||
</form>
|
||||
<button class="big-button" onclick="saveEncounter()">Confirm</button>
|
||||
<button class="big-button" onclick="removeEncounter()">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item Configuration Form -->
|
||||
<div id="configureItem" class="popup">
|
||||
<div class="content">
|
||||
<button class="close-button" onclick="closeEncounterForm()">X</button>
|
||||
<form id="item-form">
|
||||
<label for="item-form-id">Item ID</label>
|
||||
<input id="item-form-id" name="id" type="number" value="1" min="1" max="638"/>
|
||||
<label for="item-form-quantity">Quantity</label>
|
||||
<input id="item-form-quantity" name="quantity" type="number" value="1" min="1" max="20"/>
|
||||
</form>
|
||||
<button class="big-button" onclick="saveItem()">Confirm</button>
|
||||
<button class="big-button" onclick="removeItem()">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="scripts/profile.js"></script>
|
||||
</html>
|
||||
22
src/main/resources/dashboard/scripts/login.js
Normal file
22
src/main/resources/dashboard/scripts/login.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const ELEMENT_GSID_INPUT = document.getElementById("gsid");
|
||||
|
||||
function postLogin() {
|
||||
let loginData = {
|
||||
gsid: ELEMENT_GSID_INPUT.value
|
||||
}
|
||||
|
||||
fetch("/dashboard/login", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(loginData)
|
||||
}).then((response) => {
|
||||
return response.json();
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
|
||||
if(response.error) {
|
||||
alert(response.message);
|
||||
} else {
|
||||
window.location.href = "/dashboard/profile.html";
|
||||
}
|
||||
});
|
||||
}
|
||||
365
src/main/resources/dashboard/scripts/profile.js
Normal file
365
src/main/resources/dashboard/scripts/profile.js
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
const ELEMENT_GAME_SUMMARY = document.getElementById("game-summary");
|
||||
|
||||
// Dreamer elements
|
||||
const ELEMENT_DREAMER_SPRITE = document.getElementById("dreamer-sprite");
|
||||
const ELEMENT_DREAMER_SPECIES = document.getElementById("dreamer-species");
|
||||
const ELEMENT_DREAMER_NATURE = document.getElementById("dreamer-nature");
|
||||
const ELEMENT_DREAMER_NAME = document.getElementById("dreamer-name");
|
||||
const ELEMENT_DREAMER_GENDER = document.getElementById("dreamer-gender");
|
||||
const ELEMENT_DREAMER_TRAINER = document.getElementById("dreamer-trainer");
|
||||
const ELEMENT_DREAMER_TRAINER_ID = document.getElementById("dreamer-trainer-id");
|
||||
const ELEMENT_DREAMER_LEVEL = document.getElementById("dreamer-level");
|
||||
|
||||
// Encounter form elements
|
||||
const ELEMENT_ENCOUNTER_SPECIES = document.getElementById("encounter-form-species");
|
||||
const ELEMENT_ENCOUNTER_MOVE = document.getElementById("encounter-form-move");
|
||||
const ELEMENT_ENCOUNTER_FORM = document.getElementById("encounter-form-form");
|
||||
const ELEMENT_ENCOUNTER_ANIMATION = document.getElementById("encounter-form-animation");
|
||||
|
||||
// Item form elements
|
||||
const ELEMENT_ITEM_ID = document.getElementById("item-form-id");
|
||||
const ELEMENT_ITEM_QUANTITY = document.getElementById("item-form-quantity");
|
||||
|
||||
// Misc input elements
|
||||
const ELEMENT_CGEAR_SKIN_INPUT = document.getElementById("cgear-skin");
|
||||
const ELEMENT_DEX_SKIN_INPUT = document.getElementById("dex-skin");
|
||||
const ELEMENT_MUSICAL_INPUT = document.getElementById("musical");
|
||||
const ELEMENT_LEVEL_GAIN_INPUT = document.getElementById("level-gain-input");
|
||||
|
||||
// Create event listeners
|
||||
ELEMENT_ENCOUNTER_SPECIES.addEventListener("change", clampValue);
|
||||
ELEMENT_ENCOUNTER_MOVE.addEventListener("change", clampValue);
|
||||
ELEMENT_ITEM_ID.addEventListener("change", clampValue);
|
||||
ELEMENT_ITEM_QUANTITY.addEventListener("change", clampValue);
|
||||
ELEMENT_LEVEL_GAIN_INPUT.addEventListener("change", clampValue);
|
||||
|
||||
function clampValue() {
|
||||
let value = parseInt(this.value);
|
||||
|
||||
if(value < this.min) {
|
||||
this.value = this.min;
|
||||
} else if(value > this.max) {
|
||||
console.log(value);
|
||||
this.value = this.max;
|
||||
}
|
||||
}
|
||||
|
||||
// Local variables
|
||||
var encounterTableIndex = -1;
|
||||
var itemTableIndex = -1;
|
||||
var profile = {
|
||||
encounters: [],
|
||||
items: []
|
||||
};
|
||||
|
||||
function configureEncounter(index) {
|
||||
encounterTableIndex = Math.min(10, Math.min(index, profile.encounters.length));
|
||||
|
||||
// Load existing settings
|
||||
let encounter = profile.encounters[encounterTableIndex];
|
||||
ELEMENT_ENCOUNTER_SPECIES.value = encounter ? encounter.species : 1;
|
||||
ELEMENT_ENCOUNTER_MOVE.value = encounter ? encounter.move : 1;
|
||||
ELEMENT_ENCOUNTER_FORM.value = encounter ? encounter.form : 0;
|
||||
ELEMENT_ENCOUNTER_ANIMATION.value = encounter ? encounter.animation : "WALK_AROUND";
|
||||
}
|
||||
|
||||
function saveEncounter() {
|
||||
if(encounterTableIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create encounter data
|
||||
let encounterData = {
|
||||
species: ELEMENT_ENCOUNTER_SPECIES.value,
|
||||
move: ELEMENT_ENCOUNTER_MOVE.value,
|
||||
form: ELEMENT_ENCOUNTER_FORM.value,
|
||||
animation: ELEMENT_ENCOUNTER_ANIMATION.value
|
||||
}
|
||||
|
||||
// Set form to highest form available if it too great
|
||||
let maxForm = 0;
|
||||
|
||||
switch(encounterData.species) {
|
||||
case "201": maxForm = 27; break; // Unown
|
||||
case "386": maxForm = 3; break; // Deoxys
|
||||
case "412":
|
||||
case "413": maxForm = 2; break; // Burmy & Wormadam
|
||||
case "422":
|
||||
case "423":
|
||||
case "487": maxForm = 1; break; // Shellos, Gastrodon & Giratina
|
||||
case "479": maxForm = 5; break; // Rotom
|
||||
case "493": maxForm = 16; break; // Arceus
|
||||
}
|
||||
|
||||
if(encounterData.form > maxForm) {
|
||||
encounterData.form = maxForm;
|
||||
}
|
||||
|
||||
profile.encounters[encounterTableIndex] = encounterData;
|
||||
updateEncounterCell(encounterTableIndex);
|
||||
closeEncounterForm();
|
||||
}
|
||||
|
||||
function removeEncounter() {
|
||||
if(encounterTableIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let oldLength = profile.encounters.length;
|
||||
profile.encounters.splice(encounterTableIndex, 1);
|
||||
|
||||
for(let i = encounterTableIndex; i < oldLength; i++) {
|
||||
updateEncounterCell(i);
|
||||
}
|
||||
|
||||
closeEncounterForm();
|
||||
}
|
||||
|
||||
function updateEncounterCell(index) {
|
||||
let cell = document.getElementById("encounter" + index);
|
||||
let encounterData = profile.encounters[index];
|
||||
let spriteBase = "/sprites/pokemon/normal/";
|
||||
let spriteImage = spriteBase + "0.png";
|
||||
|
||||
if(encounterData) {
|
||||
spriteImage = spriteBase + encounterData.species + ".png";
|
||||
|
||||
if(encounterData.form > 0) {
|
||||
let formSpriteImage = spriteBase + encounterData.species + "-" + encounterData.form + ".png";
|
||||
|
||||
if(checkURL(formSpriteImage)){
|
||||
spriteImage = formSpriteImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cell.innerHTML = "<img src='" + spriteImage + "'/>";
|
||||
}
|
||||
|
||||
function closeEncounterForm() {
|
||||
encounterTableIndex = -1;
|
||||
window.location.href = "#";
|
||||
}
|
||||
|
||||
function configureItem(index) {
|
||||
itemTableIndex = Math.min(20, Math.min(index, profile.items.length));
|
||||
|
||||
// Loadg existing settings
|
||||
let item = profile.items[itemTableIndex];
|
||||
ELEMENT_ITEM_ID.value = item ? item.id : 1;
|
||||
ELEMENT_ITEM_QUANTITY.value = item ? item.quantity : 1;
|
||||
}
|
||||
|
||||
function saveItem() {
|
||||
if(itemTableIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemData = {
|
||||
id: ELEMENT_ITEM_ID.value,
|
||||
quantity: ELEMENT_ITEM_QUANTITY.value
|
||||
}
|
||||
|
||||
profile.items[itemTableIndex] = itemData;
|
||||
updateItemCell(itemTableIndex);
|
||||
closeItemForm();
|
||||
}
|
||||
|
||||
function removeItem() {
|
||||
if(itemTableIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let oldLength = profile.items.length;
|
||||
profile.items.splice(itemTableIndex, 1);
|
||||
|
||||
for(let i = itemTableIndex; i < oldLength; i++) {
|
||||
updateItemCell(i);
|
||||
}
|
||||
|
||||
closeItemForm();
|
||||
}
|
||||
|
||||
function updateItemCell(index) {
|
||||
let cell = document.getElementById("item" + index);
|
||||
let item = profile.items[index];
|
||||
let spriteBase = "/sprites/items/";
|
||||
let spriteImage = spriteBase + "0.png";
|
||||
let quantityStr = "";
|
||||
|
||||
if(item) {
|
||||
let newSpriteImage = spriteBase + item.id + ".png";
|
||||
quantityStr = "x" + item.quantity;
|
||||
|
||||
if(checkURL(newSpriteImage)){
|
||||
spriteImage = newSpriteImage;
|
||||
}
|
||||
}
|
||||
|
||||
cell.innerHTML = "<img src='" + spriteImage + "'/><br>" + quantityStr;
|
||||
}
|
||||
|
||||
function closeItemForm() {
|
||||
itemTableIndex = -1;
|
||||
window.location.href = "#";
|
||||
}
|
||||
|
||||
async function fetchData(path) {
|
||||
return fetchData(path, "GET", null);
|
||||
}
|
||||
|
||||
async function fetchData(path, method, body) {
|
||||
let response = await fetch(path, {
|
||||
method: method,
|
||||
body: body
|
||||
});
|
||||
|
||||
// Return to login page if unauthorized
|
||||
if(response.status == 401) {
|
||||
window.location.href = "/dashboard/login.html";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch(error) {
|
||||
window.alert(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fetchDlcData() {
|
||||
let cgearType = profile.gameVersion.includes("2") ? "CGEAR2" : "CGEAR"; // Not a good way to do this!
|
||||
|
||||
// Fetch CGear skins
|
||||
fetchData("/dashboard/dlc?type=" + cgearType).then((response) => {
|
||||
addValuesToComboBox(ELEMENT_CGEAR_SKIN_INPUT, response);
|
||||
ELEMENT_CGEAR_SKIN_INPUT.value = profile.cgearSkin;
|
||||
});
|
||||
|
||||
// Fetch Dex skins
|
||||
fetchData("/dashboard/dlc?type=ZUKAN").then((response) => {
|
||||
addValuesToComboBox(ELEMENT_DEX_SKIN_INPUT, response);
|
||||
ELEMENT_DEX_SKIN_INPUT.value = profile.dexSkin;
|
||||
});
|
||||
|
||||
// Fetch musicals
|
||||
fetchData("/dashboard/dlc?type=MUSICAL").then((response) => {
|
||||
addValuesToComboBox(ELEMENT_MUSICAL_INPUT, response);
|
||||
ELEMENT_MUSICAL_INPUT.value = profile.musical;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO
|
||||
function fetchProfileData() {
|
||||
fetchData("/dashboard/profile").then((response) => {
|
||||
let gameVersion = response["gameVersion"];
|
||||
let dreamerSprite = response["dreamerSprite"];
|
||||
let dreamerInfo = response["dreamerInfo"];
|
||||
let encounters = response["encounters"];
|
||||
let items = response["items"];
|
||||
let cgearSkin = response["cgearSkin"];
|
||||
let dexSkin = response["dexSkin"];
|
||||
let musical = response["musical"];
|
||||
let levelsGained = response["levelsGained"];
|
||||
|
||||
// Update game summary
|
||||
profile.gameVersion = gameVersion;
|
||||
ELEMENT_GAME_SUMMARY.innerHTML = "Game Card in use: " + gameVersion;
|
||||
|
||||
// Update dreamer summary
|
||||
if(dreamerInfo) {
|
||||
let species = dreamerInfo["species"];
|
||||
let nature = dreamerInfo["nature"];
|
||||
let nickname = dreamerInfo["nickname"];
|
||||
let gender = dreamerInfo["gender"];
|
||||
let trainerName = dreamerInfo["trainerName"];
|
||||
let trainerId = dreamerInfo["trainerId"];
|
||||
let level = dreamerInfo["level"];
|
||||
|
||||
// Set element values
|
||||
ELEMENT_DREAMER_SPRITE.innerHTML = "<image src='" + dreamerSprite + "'/>";
|
||||
ELEMENT_DREAMER_SPECIES.innerHTML = "#" + species;
|
||||
ELEMENT_DREAMER_NATURE.innerHTML = stringToWord(nature);
|
||||
ELEMENT_DREAMER_NAME.innerHTML = nickname;
|
||||
ELEMENT_DREAMER_GENDER.innerHTML = stringToWord(gender);
|
||||
ELEMENT_DREAMER_TRAINER.innerHTML = trainerName;
|
||||
ELEMENT_DREAMER_TRAINER_ID.innerHTML = trainerId;
|
||||
ELEMENT_DREAMER_LEVEL.innerHTML = level;
|
||||
}
|
||||
|
||||
// Update encounter table
|
||||
if(encounters){
|
||||
profile.encounters = encounters;
|
||||
|
||||
for(let i = 0; i < 10; i++) {
|
||||
updateEncounterCell(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Update item table
|
||||
if(items){
|
||||
profile.items = items;
|
||||
|
||||
for(let i = 0; i < 20; i++) {
|
||||
updateItemCell(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected DLC
|
||||
profile.cgearSkin = cgearSkin ? cgearSkin : "none";
|
||||
profile.dexSkin = dexSkin ? dexSkin : "none";
|
||||
profile.musical = musical ? musical : "none";
|
||||
fetchDlcData();
|
||||
|
||||
// Update level gain
|
||||
ELEMENT_LEVEL_GAIN_INPUT.value = levelsGained;
|
||||
|
||||
// Show div
|
||||
document.getElementById("main-container").style.display = "grid";
|
||||
});
|
||||
}
|
||||
|
||||
function postProfileData() {
|
||||
// Construct body
|
||||
let profileData = {
|
||||
encounters: profile.encounters,
|
||||
items: profile.items,
|
||||
cgearSkin: ELEMENT_CGEAR_SKIN_INPUT.value,
|
||||
dexSkin: ELEMENT_DEX_SKIN_INPUT.value,
|
||||
musical: ELEMENT_MUSICAL_INPUT.value,
|
||||
gainedLevels: ELEMENT_LEVEL_GAIN_INPUT.value
|
||||
}
|
||||
|
||||
// Send data
|
||||
fetchData("/dashboard/profile", "POST", JSON.stringify(profileData)).then((response) => {
|
||||
alert(response.message);
|
||||
});
|
||||
}
|
||||
|
||||
function postLogout() {
|
||||
fetchData("/dashboard/logout", "POST", null).then((response) => {
|
||||
// Assume it succeeded
|
||||
window.location.href = "/dashboard/login.html";
|
||||
});
|
||||
}
|
||||
|
||||
// TODO bad
|
||||
function checkURL(url) {
|
||||
var request = new XMLHttpRequest();
|
||||
request.open('HEAD', url, false);
|
||||
request.send();
|
||||
return request.status == 200;
|
||||
}
|
||||
|
||||
function stringToWord(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function addValuesToComboBox(selectorElement, values) {
|
||||
for(i in values) {
|
||||
let value = values[i];
|
||||
selectorElement.options[selectorElement.options.length] = new Option(value, value);
|
||||
}
|
||||
}
|
||||
38
src/main/resources/dashboard/styles/login.css
Normal file
38
src/main/resources/dashboard/styles/login.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
body {
|
||||
background-color: #191919;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
color: white;
|
||||
background-color: #262626;
|
||||
border: 0px;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
background-color: #3D3D3D;
|
||||
border: 0px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
padding: 16px 32px;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.root-container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 25%;
|
||||
transform: translate(-50%, 0%);
|
||||
padding: 10px;
|
||||
}
|
||||
164
src/main/resources/dashboard/styles/profile.css
Normal file
164
src/main/resources/dashboard/styles/profile.css
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
body {
|
||||
background-color: #191919;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
form {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
input, select {
|
||||
color: white;
|
||||
background-color: #262626;
|
||||
border: 0px;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
background-color: #3D3D3D;
|
||||
border: 0px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.root-container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 10px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr;
|
||||
grid-auto-flow: column;
|
||||
gap: 5px 10px;
|
||||
}
|
||||
|
||||
.grid-container input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px 4px;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dreamer-summary {
|
||||
width: 100%;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dreamer-summary th {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.dreamer-summary td {
|
||||
width: 96px;
|
||||
background-color: #262626;
|
||||
border-radius: 10px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.dreamer-sprite {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.popup:target {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.popup .close-button {
|
||||
position: absolute;
|
||||
background-color: #3D3D3D;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.popup .content {
|
||||
background-color: #191919;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 25%;
|
||||
transform: translate(-50%, 0%);
|
||||
padding: 20px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.encounter-image-table {
|
||||
width: 100%;
|
||||
border-spacing: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.encounter-image-table td {
|
||||
background-color: #262626;
|
||||
border-radius: 10px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-table {
|
||||
width: 100%;
|
||||
border-spacing: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item-table td {
|
||||
background-color: #262626;
|
||||
border-radius: 10px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-table td a {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.big-button {
|
||||
font-size: 16px;
|
||||
padding: 16px 32px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user