Initial commit

This commit is contained in:
Les De Ridder 2017-08-12 23:54:18 +02:00
commit 3b57471d3d
16 changed files with 587 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.dub
docs.json
__dummy.html
*.o
*.obj
__test__*__
fkg-api-proxy
source/config.d
data/

31
LICENSE Normal file
View File

@ -0,0 +1,31 @@
University of Illinois/NCSA
Open Source License
Copyright (c) 2017, Les De Ridder
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal with
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimers.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimers in the
documentation and/or other materials provided with the distribution.
* Neither the name of fkg-api-proxy nor the names of its contributors may be
used to endorse or promote products derived from this Software without
specific prior written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE
SOFTWARE.

35
README.md Normal file
View File

@ -0,0 +1,35 @@
# Flower Knight Girl API Proxy
## Description
This is a proxy for DMM's version of Flower Knight Girl. Currently its main feature is providing a way to use parts of Nutaku's translations (or user-provided translations) in the DMM version.
## Dependencies
* A D compiler
* dub
* fish (for generating translation data)
* csvquote (for generating translation data)
## Translations
To use the proxy for translations based on the Nutaku version, you need to generate the data from the Nutaku version's master data. You can do this by following these steps:
0. Install [fish](https://fishshell.com/) and [csvquote](https://github.com/dbro/csvquote)
1. Save the /master/getMaster API response of the Nutaku version
2. Decompress the response (zlib) and name the file 'nutakuMaster.json'
3. Run `./generate-translations.fish`
4. Verify the translations were generated correctly by looking at the output of `head data/*`
## Building
1. Copy `source/config.d.example` to `source/config.d` and edit it
2. Run `dub build`
## Using
1. Start the proxy (`./fkg-api-proxy`)
2. Make your browser send the API requests through the proxy (e.g. by adding an /etc/hosts entry)
## Disclaimer
This is most likely against DMM's ToS. Use at your own risk. That said, it should be unlikely that you'd get banned for using this, since it shouldn't be detectable from DMM's side.

8
dub.sdl Normal file
View File

@ -0,0 +1,8 @@
name "fkg-api-proxy"
description "A proxy for Flower Knight Girl's API"
authors "Les De Ridder"
copyright "Copyright © 2017, Les De Ridder"
license "NCSA"
dependency "vibe-d" version="~>0.8.0"
versions "Have_botan"
stringImportPaths "data"

16
dub.selections.json Normal file
View File

@ -0,0 +1,16 @@
{
"fileVersion": 1,
"versions": {
"botan": "1.12.9",
"botan-math": "1.0.3",
"diet-ng": "1.3.0",
"eventcore": "0.8.12",
"libasync": "0.8.3",
"libevent": "2.0.2+2.0.16",
"memutils": "0.4.9",
"openssl": "1.1.5+1.0.1g",
"taggedalgebraic": "0.10.7",
"vibe-core": "1.0.0",
"vibe-d": "0.8.0"
}
}

32
generate-translations.fish Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/fish
#TODO: Maybe do this in D (at compile time) instead?
function extractFields
set name $argv[1]
set firstColumn $argv[2]
set secondColumn $argv[3]
# For some tables (e.g. masterStage), commas aren't allowed even in quoted strings (though the Nutaku version handles them just fine)
csvquote | cut -d, -f $firstColumn,$secondColumn | sed "s/\"/\\\\\"/g;s/\(.*\),\(.*\)/$name\[\1\] = \"\2\";/;s/\x1F/、/g"
end
function extractCsv
set table $argv[1]
jq -r '.master'$table nutakuMaster.json | base64 -d
end
extractCsv 'Character' | extractFields 'characterNames' 1 6 > data/characterNames.data.d
extractCsv 'CharacterSkill' | extractFields 'skillNames' 1 2 > data/skillNames.data.d
extractCsv 'CharacterSkill' | extractFields 'skillDescriptions' 1 7 > data/skillDescriptions.data.d
extractCsv 'Stage' | extractFields 'stageNames' 1 2 > data/stageNames.data.d
extractCsv 'Mission' | extractFields 'missionNames' 1 2 > data/missionNames.data.d
extractCsv 'Mission' | extractFields 'missionDescriptions' 1 3 > data/missionDescriptions.data.d
extractCsv 'Mission' | extractFields 'missionRewards' 1 4 > data/missionRewards.data.d
extractCsv 'Dungeon' | extractFields 'dungeonNames' 10 12 > data/dungeonNames.data.d
extractCsv 'Guest' | extractFields 'guestPartyNames' 1 2 > data/guestPartyNames.data.d

253
source/app.d Normal file
View File

@ -0,0 +1,253 @@
import vibe.vibe;
import std.base64 : Base64;
import std.conv : to;
import botan.filters.pipe : Pipe;
import botan.filters.b64_filt : Base64Encoder, Base64Decoder;
import botan.filters.transform_filter : TransformationFilter;
import botan.compression.zlib: ZlibDecompression;
import botan.libstate.lookup : getCipher;
import botan.algo_base.symkey : SymmetricKey, InitializationVector;
import botan.utils.types;
import config;
void main()
{
auto router = new URLRouter;
router.get("*", reverseProxyRequest(serverHost, serverPort));
router.post("/api/v1/*", &onAPIRequest);
auto settings = new HTTPServerSettings;
settings.port = proxyPortNumber;
settings.options = HTTPServerOption.parseURL | HTTPServerOption.errorStackTraces;
listenHTTP(settings, router);
runApplication();
}
void onAPIRequest(HTTPServerRequest request, HTTPServerResponse response)
{
auto apiEndpoint = request.path.split("/api/v1")[1];
auto nonce = request.headers["auth_nonce"];
auto requestBody = request.bodyReader.readAll();
auto requestJson = getRequestJson(requestBody, apiEndpoint, nonce);
handleRequest(apiEndpoint, requestJson);
//TODO: Allow request editing (requires recalculation of signature)
auto apiResponse = sendRequest(apiEndpoint, request.headers.dup, requestBody);
auto responseBody = apiResponse.bodyReader.readAll();
auto responseJson = getResponseJson(responseBody, apiEndpoint, nonce);
auto finalResponseJson = handleResponse(apiEndpoint, responseJson);
auto finalResponseBody = constructResponseBody(apiEndpoint, finalResponseJson, nonce);
sendResponse(response, apiResponse, finalResponseBody);
import std.stdio : writeln;
writeln(apiEndpoint);
}
Json getRequestJson(ubyte[] input, string endpoint, string nonce)
{
auto originalRequestPipe = Pipe
(
new Base64Decoder,
getCipher
(
cipherName,
SymmetricKey((cast(ubyte[]) keyString).ptr, keyString.length),
InitializationVector((cast(ubyte[]) nonce).ptr, nonce.length),
DECRYPTION
)
);
originalRequestPipe.processMsg(input.ptr, input.length);
auto requestJsonString = originalRequestPipe.toString;
return requestJsonString.parseJson;
}
Json getResponseJson(ubyte[] input, string endpoint, string nonce)
{
import std.zlib : uncompress;
switch(endpoint)
{
case "/config/getGameConfig":
return getRequestJson(input, endpoint, nonce);
case "/master/getMaster":
auto decompressedInput = cast(ubyte[])uncompress(input); //TODO: Use pipe instead (how?)
auto responseJsonString = cast(string) decompressedInput;
return responseJsonString.parseJson;
default:
auto decompressedInput = cast(ubyte[])uncompress(input); //TODO: Use pipe instead (how?)
auto responsePipe = Pipe
(
new Base64Decoder
);
responsePipe.processMsg(decompressedInput.ptr, decompressedInput.length);
auto responseJsonString = responsePipe.toString;
return responseJsonString.parseJson;
}
}
void handleRequest(string endpoint, Json json)
{
}
Json handleResponse(string endpoint, Json json)
{
if(endpoint == "/master/getMaster")
{
import models.master;
auto getMasterResponse = json.deserializeJson!GetMasterResponse;
foreach(character; getMasterResponse.masterCharacter)
{
auto characterId = character[0].to!uint;
import translations.character;
if(characterId in characterNames)
{
character[5] = characterNames[characterId];
}
}
foreach(characterSkill; getMasterResponse.masterCharacterSkill)
{
auto skillId = characterSkill[0].to!uint;
import translations.characterSkill;
if(skillId in skillNames)
{
characterSkill[1] = skillNames[skillId];
characterSkill[6] = skillDescriptions[skillId];
}
}
foreach(stage; getMasterResponse.masterStage)
{
auto stageId = stage[0].to!uint;
import translations.stage;
if(stageId in stageNames)
{
stage[1] = stageNames[stageId];
}
}
foreach(mission; getMasterResponse.masterMission)
{
//TODO: Figure out a way to find the wrong ones
auto missionId = mission[0].to!uint;
import translations.mission;
if(missionId in missionNames)
{
mission[1] = missionNames[missionId];
mission[2] = missionDescriptions[missionId];
mission[3] = missionRewards[missionId];
}
}
foreach(dungeon; getMasterResponse.masterDungeon)
{
auto dungeonId = dungeon[10].to!uint;
import translations.dungeon;
if(dungeonId in dungeonNames)
{
dungeon[12] = dungeonNames[dungeonId];
}
}
foreach(guest; getMasterResponse.masterGuest)
{
auto guestId = guest[0].to!uint;
import translations.guest;
if(guestId in guestPartyNames)
{
guest[1] = guestPartyNames[guestId];
}
}
return getMasterResponse.serializeToJson;
}
return json;
}
ubyte[] constructResponseBody(string endpoint, Json json, string nonce)
{
import std.zlib : compress;
auto jsonStringBytes = cast(ubyte[]) json.toString;
switch(endpoint)
{
case "/config/getGameConfig":
auto responsePipe = Pipe
(
getCipher
(
cipherName,
SymmetricKey((cast(ubyte[]) keyString).ptr, keyString.length),
InitializationVector((cast(ubyte[]) nonce).ptr, nonce.length),
ENCRYPTION
),
new Base64Encoder
);
//TODO: Refactor this
if(jsonStringBytes.length % 16 != 0)
{
jsonStringBytes.length += 16 - jsonStringBytes.length % 16;
}
responsePipe.processMsg(jsonStringBytes.ptr, jsonStringBytes.length);
return cast(ubyte[]) responsePipe.toString;
case "/master/getMaster":
return compress(jsonStringBytes); //TODO: Use pipe instead (how?)
default:
auto responsePipe = Pipe
(
new Base64Encoder
);
responsePipe.processMsg(jsonStringBytes.ptr, jsonStringBytes.length);
return compress(responsePipe.toString);
}
}
HTTPClientResponse sendRequest(string endpoint, InetHeaderMap headers, ubyte[] requestBody)
{
return requestHTTP("http://" ~ serverHost ~ "/api/v1" ~ endpoint,
(scope HTTPClientRequest r)
{
r.method = HTTPMethod.POST;
r.headers = headers.dup;
r.writeBody(requestBody, r.headers["Content-Type"]);
}
);
}
void sendResponse(HTTPServerResponse response, HTTPClientResponse apiResponse, ubyte[] responseBody)
{
if("Transfer-Encoding" in apiResponse.headers) { apiResponse.headers.remove("Transfer-Encoding"); }
response.statusCode = apiResponse.statusCode;
response.statusPhrase = apiResponse.statusPhrase;
response.headers = apiResponse.headers.dup;
response.writeBody(responseBody);
}

9
source/config.d.example Normal file
View File

@ -0,0 +1,9 @@
module config;
enum keyString = "encryption key";
enum cipherName = "cipher name";
enum serverHost = "API server hostname";
enum serverPort = 80;
enum proxyPortNumber = 80; //edit this if you want to run the proxy as a normal user and use a reverse proxy

94
source/models/master.d Normal file
View File

@ -0,0 +1,94 @@
module models.master;
import models.response;
import std.typecons : Nullable;
class GetMasterResponse : Response
{
Nullable!Base64CSV masterTutorial;
Nullable!Base64CSV masterStory;
Nullable!Base64CSV masterPanelMission;
Nullable!Base64CSV masterEquipmentPurificationGroups;
Nullable!Base64CSV masterBossAi;
Nullable!Base64CSV masterAlbumDungeon;
Nullable!Base64CSV masterMissionItem;
Nullable!Base64CSV masterNavigation;
Nullable!Base64CSV masterCharacterParticular;
Nullable!Base64CSV masterCharacterTrainableLeaderSkill;
Nullable!Base64CSV masterDefenseSetting;
Nullable!Base64CSV masterGardenSpecialCharacter;
Nullable!Base64CSV masterCharacterEquipmentEvolve;
Nullable!Base64CSV masterCharacterSkill;
Nullable!Base64CSV masterCollaboSymphony;
Nullable!Base64CSV masterCharacterEquipmentLevelGroup;
Nullable!Base64CSV masterCharacterQuestItem;
Nullable!Base64CSV masterSurvey;
Nullable!Base64CSV masterCharacterMyPagePattern;
Nullable!Base64CSV masterGift;
Nullable!Base64CSV masterDungeonRecommendPrioritys;
Nullable!Base64CSV masterAlbumQuestClear;
Nullable!Base64CSV masterStageEvaluationItem;
Nullable!Base64CSV masterItem;
Nullable!Base64CSV masterCharacterFlowerings;
Nullable!Base64CSV masterPlant;
Nullable!Base64CSV masterCharacterTrainableLeaderSkillAcquireCondition;
Nullable!Base64CSV masterEquipmentPurifications;
Nullable!Base64CSV masterCharacterEquipment;
Nullable!Base64CSV masterCharacterTextResource;
Nullable!Base64CSV masterAlbumTheaterGroup;
Nullable!Base64CSV masterMission;
Nullable!Base64CSV masterCharacterMypageVoiceResourceGroup;
Nullable!Base64CSV masterGuestCharacter;
Nullable!Base64CSV masterCharacterLeaderSkill;
Nullable!Base64CSV masterCharacterSkinNotExistVoice;
Nullable!Base64CSV masterStage;
Nullable!Base64CSV masterCampaignStaminaDungeon;
Nullable!Base64CSV masterGardenMakeoverItemShop;
Nullable!Base64CSV masterGardenMakeoverItem;
Nullable!Base64CSV masterWhaleHomeBanner;
Nullable!Base64CSV masterSwanBoatRaceGroup;
Nullable!Base64CSV masterSynopsis;
Nullable!Base64CSV masterCharacterLevel;
Nullable!Base64CSV masterPayment;
Nullable!Base64CSV masterCharacterBook;
Nullable!Base64CSV masterAchievement;
Nullable!Base64CSV masterCharacterCategory;
Nullable!Base64CSV masterAvailableItem;
Nullable!Base64CSV masterSwanBoatRaceSchedule;
Nullable!Base64CSV masterCharacterMypageVoiceResource;
Nullable!Base64CSV masterCharacterLevelGroup;
Nullable!Base64CSV masterCharacterQuest;
Nullable!Base64CSV masterCharacterEvolveGroup;
Nullable!Base64CSV masterCharacterEvolve;
Nullable!Base64CSV masterKizunaPoint;
Nullable!Base64CSV masterGiftPurifications;
Nullable!Base64CSV masterHomeBgm;
Nullable!Base64CSV masterLetter;
Nullable!Base64CSV masterPaymentCampaign;
Nullable!Base64CSV masterLevel;
Nullable!Base64CSV masterHomeBanner;
Nullable!Base64CSV masterEquipmentPurificationMaterialItems;
Nullable!Base64CSV masterWorldMap;
Nullable!Base64CSV masterBoost;
Nullable!Base64CSV masterCharacterEquipmentEvolveGroup;
Nullable!Base64CSV masterGardenPlantFlowerBook;
Nullable!Base64CSV masterCharacterFloweringGroups;
Nullable!Base64CSV masterGardenPlantInsectBook;
Nullable!Base64CSV masterWhaleEvent;
Nullable!Base64CSV masterCharacterTrainableLeaderSkillLevel;
Nullable!Base64CSV masterCharacterPurification;
Nullable!Base64CSV masterDungeon;
Nullable!Base64CSV masterShop;
Nullable!Base64CSV masterGuest;
Nullable!Base64CSV masterAlbumTheater;
Nullable!Base64CSV masterCharacter;
Nullable!Base64CSV masterCharacterEquipmentLevel;
Nullable!Base64CSV masterStageConcept;
Nullable!Base64CSV masterDungeonRecommendTotalPowerGroup;
Nullable!Base64CSV masterCharacterSkin;
Nullable!Base64CSV masterItemPurification;
Nullable!Base64CSV masterStageTimeSchedule;
Nullable!Base64CSV masterGardenFlowerItem;
Nullable!Base64CSV masterPlantItem;
}

45
source/models/response.d Normal file
View File

@ -0,0 +1,45 @@
module models.response;
import std.base64 : Base64;
import std.csv : csvReader;
import std.array : array;
import std.algorithm : map;
import std.string : splitLines;
import std.array : split, join;
import std.typecons : Nullable;
class Response
{
string resultCode;
string buildVersion;
Nullable!string serverTime;
Nullable!string version_;
}
struct Base64CSV
{
string[][] records;
alias records this;
@safe //I guess
string toRepresentation() const
{
auto csvString = records.map!(r => r.join(",")).array.join("\n").idup ~ "\n";
return Base64.encode(cast(const(ubyte[])) csvString);
}
@safe //I guess
static Base64CSV fromRepresentation(string input)
{
auto inputString = cast(string) Base64.decode(input).idup;
auto records = inputString.splitLines.map!(line => line.split(',')).array;
auto base64csv = Base64CSV(records);
assert(base64csv.toRepresentation == input);
return base64csv;
}
}

View File

@ -0,0 +1,8 @@
module translations.character;
string[uint] characterNames;
static this()
{
mixin(import("characterNames.data.d"));
}

View File

@ -0,0 +1,10 @@
module translations.characterSkill;
string[uint] skillNames;
string[uint] skillDescriptions;
static this()
{
mixin(import("skillNames.data.d"));
mixin(import("skillDescriptions.data.d"));
}

View File

@ -0,0 +1,8 @@
module translations.dungeon;
string[uint] dungeonNames;
static this()
{
mixin(import("dungeonNames.data.d"));
}

View File

@ -0,0 +1,8 @@
module translations.guest;
string[uint] guestPartyNames;
static this()
{
mixin(import("guestPartyNames.data.d"));
}

View File

@ -0,0 +1,12 @@
module translations.mission;
string[uint] missionNames;
string[uint] missionDescriptions;
string[uint] missionRewards;
static this()
{
mixin(import("missionNames.data.d"));
mixin(import("missionDescriptions.data.d"));
mixin(import("missionRewards.data.d"));
}

View File

@ -0,0 +1,8 @@
module translations.stage;
string[uint] stageNames;
static this()
{
mixin(import("stageNames.data.d"));
}