mirror of
https://github.com/PretendoNetwork/BOSS.git
synced 2026-04-19 21:47:22 -05:00
Merge pull request #6 from DaniElectra/spr
This commit is contained in:
commit
c7fc1bbcdc
66
package-lock.json
generated
66
package-lock.json
generated
|
|
@ -11,11 +11,12 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.395.0",
|
||||
"@pretendonetwork/boss-crypto": "^1.0.0",
|
||||
"@pretendonetwork/grpc": "^1.0.4",
|
||||
"@pretendonetwork/grpc": "^1.0.6",
|
||||
"@typegoose/auto-increment": "^3.6.1",
|
||||
"boss-js": "github:PretendoNetwork/boss-js",
|
||||
"cacache": "^18.0.0",
|
||||
"colors": "^1.4.0",
|
||||
"dicer": "^0.3.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-subdomain": "^1.0.5",
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dicer": "^0.2.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/morgan": "^1.9.4",
|
||||
|
|
@ -1166,9 +1168,9 @@
|
|||
"integrity": "sha512-ybd3sB356v5Azxj99R62+7kytgAzfUYuXRJbdOznGL6infgCJ056TjTadN4V48m7t+3f6sPOUgo9YWUFNxlLLg=="
|
||||
},
|
||||
"node_modules/@pretendonetwork/grpc": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.4.tgz",
|
||||
"integrity": "sha512-r/OTdt5nhPVScxivbLbJxHHaFddgUe03zTSHzJgaY96NAxA9rM+3Up9JJcgv5traIF6ptKYVJck4kkR2PBOPgw==",
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.6.tgz",
|
||||
"integrity": "sha512-kTK4lO8AdrQ5GOvYdJ7sqvIP3ubn5TGqGGqjVpgCTSiVBvBmlnz3fQkoDHmYw2WeA0CNtUx2dROG3Juiy5t7BQ==",
|
||||
"dependencies": {
|
||||
"long": "^5.2.1",
|
||||
"protobufjs": "^7.2.3"
|
||||
|
|
@ -1853,6 +1855,15 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dicer": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/dicer/-/dicer-0.2.4.tgz",
|
||||
"integrity": "sha512-fOmnb+GtVJKwGf73zvgLsVJdj+L+InnbLMTGo1/tjCjrzDIbqh5ijziL8AkPkOEfAa3ODLy1uYr/sv5QEOYnWQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
|
||||
|
|
@ -2848,6 +2859,17 @@
|
|||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dicer": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.1.tgz",
|
||||
"integrity": "sha512-ObioMtXnmjYs3aRtpIJt9rgQSPCIhKVkFPip+E9GUDyWl8N435znUxK/JfNwGZJ2wnn5JKQ7Ly3vOK5Q5dylGA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
|
|
@ -4905,6 +4927,14 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
|
@ -6304,9 +6334,9 @@
|
|||
"integrity": "sha512-ybd3sB356v5Azxj99R62+7kytgAzfUYuXRJbdOznGL6infgCJ056TjTadN4V48m7t+3f6sPOUgo9YWUFNxlLLg=="
|
||||
},
|
||||
"@pretendonetwork/grpc": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.4.tgz",
|
||||
"integrity": "sha512-r/OTdt5nhPVScxivbLbJxHHaFddgUe03zTSHzJgaY96NAxA9rM+3Up9JJcgv5traIF6ptKYVJck4kkR2PBOPgw==",
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-1.0.6.tgz",
|
||||
"integrity": "sha512-kTK4lO8AdrQ5GOvYdJ7sqvIP3ubn5TGqGGqjVpgCTSiVBvBmlnz3fQkoDHmYw2WeA0CNtUx2dROG3Juiy5t7BQ==",
|
||||
"requires": {
|
||||
"long": "^5.2.1",
|
||||
"protobufjs": "^7.2.3"
|
||||
|
|
@ -6868,6 +6898,15 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/dicer": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/dicer/-/dicer-0.2.4.tgz",
|
||||
"integrity": "sha512-fOmnb+GtVJKwGf73zvgLsVJdj+L+InnbLMTGo1/tjCjrzDIbqh5ijziL8AkPkOEfAa3ODLy1uYr/sv5QEOYnWQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/express": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
|
||||
|
|
@ -7598,6 +7637,14 @@
|
|||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="
|
||||
},
|
||||
"dicer": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.1.tgz",
|
||||
"integrity": "sha512-ObioMtXnmjYs3aRtpIJt9rgQSPCIhKVkFPip+E9GUDyWl8N435znUxK/JfNwGZJ2wnn5JKQ7Ly3vOK5Q5dylGA==",
|
||||
"requires": {
|
||||
"streamsearch": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
|
|
@ -9063,6 +9110,11 @@
|
|||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="
|
||||
},
|
||||
"streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.395.0",
|
||||
"@pretendonetwork/boss-crypto": "^1.0.0",
|
||||
"@pretendonetwork/grpc": "^1.0.4",
|
||||
"@pretendonetwork/grpc": "^1.0.6",
|
||||
"@typegoose/auto-increment": "^3.6.1",
|
||||
"boss-js": "github:PretendoNetwork/boss-js",
|
||||
"cacache": "^18.0.0",
|
||||
"colors": "^1.4.0",
|
||||
"dicer": "^0.3.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-subdomain": "^1.0.5",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"xmlbuilder": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dicer": "^0.2.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/morgan": "^1.9.4",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ export const config: Config = {
|
|||
address: process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_ADDRESS?.trim() || '',
|
||||
port: Number(process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_PORT?.trim() || ''),
|
||||
api_key: process.env.PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY?.trim() || ''
|
||||
},
|
||||
friends: {
|
||||
address: process.env.PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_ADDRESS?.trim() || '',
|
||||
port: Number(process.env.PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_PORT?.trim() || ''),
|
||||
api_key: process.env.PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_API_KEY?.trim() || ''
|
||||
}
|
||||
},
|
||||
mongoose: {
|
||||
|
|
@ -118,6 +123,18 @@ if (!config.grpc.account.api_key) {
|
|||
errors.push('Failed to find account server gRPC API key. Set the PN_BOSS_CONFIG_GRPC_ACCOUNT_SERVER_API_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.friends.address) {
|
||||
errors.push('Failed to find account server gRPC address. Set the PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_ADDRESS environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.friends.port) {
|
||||
errors.push('Failed to find account server gRPC port. Set the PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_PORT environment variable');
|
||||
}
|
||||
|
||||
if (!config.grpc.friends.api_key) {
|
||||
errors.push('Failed to find account server gRPC API key. Set the PN_BOSS_CONFIG_GRPC_FRIENDS_SERVER_API_KEY environment variable');
|
||||
}
|
||||
|
||||
if (!config.mongoose.connection_string) {
|
||||
errors.push('Failed to find MongoDB connection string. Set the PN_BOSS_CONFIG_MONGO_CONNECTION_STRING environment variable');
|
||||
}
|
||||
|
|
@ -177,4 +194,4 @@ if (errors.length !== 0) {
|
|||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { CECData } from '@/models/cec-data';
|
||||
import { CECSlot } from '@/models/cec-slot';
|
||||
import { Task } from '@/models/task';
|
||||
import { File } from '@/models/file';
|
||||
import { config } from '@/config-manager';
|
||||
import { HydratedCECDataDocument } from '@/types/mongoose/cec-data';
|
||||
import { HydratedCECSlotDocument, ICECSlot } from '@/types/mongoose/cec-slot';
|
||||
import { HydratedTaskDocument, ITask } from '@/types/mongoose/task';
|
||||
import { HydratedFileDocument, IFile } from '@/types/mongoose/file';
|
||||
|
||||
|
|
@ -23,7 +27,7 @@ export function connection(): mongoose.Connection {
|
|||
|
||||
export function verifyConnected(): void {
|
||||
if (!connection()) {
|
||||
throw new Error('Cannot make database requets without being connected');
|
||||
throw new Error('Cannot make database requests without being connected');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,4 +151,36 @@ export function getTaskFileByDataID(dataID: bigint): Promise<HydratedFileDocumen
|
|||
deleted: false,
|
||||
data_id: dataID
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getDuplicateCECData(pid: number, gameID: number): Promise<HydratedCECDataDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
return CECData.findOne<HydratedCECDataDocument>({
|
||||
creator_pid: pid,
|
||||
game_id: gameID
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRandomCECData(pids: number[], gameID: number): Promise<HydratedCECDataDocument | null> {
|
||||
verifyConnected();
|
||||
|
||||
// * We search through the CECSlot so that everyone has the same chance of getting their data picked up
|
||||
const filter: mongoose.FilterQuery<ICECSlot> = {
|
||||
creator_pid: {
|
||||
$in: pids,
|
||||
},
|
||||
game_id: gameID
|
||||
};
|
||||
|
||||
const count = await CECSlot.countDocuments(filter);
|
||||
const rand = Math.floor(Math.random() * count);
|
||||
|
||||
const cecSlot = await CECSlot.findOne<HydratedCECSlotDocument>(filter).skip(rand);
|
||||
|
||||
if (cecSlot) {
|
||||
return CECData.findById(cecSlot.latest_data_id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import express from 'express';
|
||||
import { getUserDataByPID } from '@/util';
|
||||
import { getNEXDataByPID } from '@/util';
|
||||
|
||||
export default async function authenticationMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
|
||||
if (request.pid) {
|
||||
// TODO - Handle 3DS NEX accounts
|
||||
request.pnid = await getUserDataByPID(request.pid);
|
||||
// TODO - Get users PNIDs
|
||||
request.nexAccount = await getNEXDataByPID(request.pid);
|
||||
}
|
||||
|
||||
return next();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import express from 'express';
|
||||
import RequestException from '@/request-exception';
|
||||
import type { UserAgentInfo } from '@/types/common/user-agent-info';
|
||||
// import RequestException from '@/request-exception';
|
||||
import { CTRSystemModel, UserAgentInfo } from '@/types/common/user-agent-info';
|
||||
|
||||
const FIRMWARE_PATCH_REGION_WIIU_REGEX = /(\d)([JEU])/;
|
||||
|
||||
|
|
@ -8,7 +8,9 @@ export default function parseUserAgentMiddleware(request: express.Request, respo
|
|||
const userAgent = request.header('user-agent');
|
||||
|
||||
if (!userAgent) {
|
||||
return next(new RequestException('Missing or invalid user agent', 400));
|
||||
// TODO - Error when no user agent is given!
|
||||
// return next(new RequestException('Missing or invalid user agent', 400));
|
||||
return next();
|
||||
}
|
||||
|
||||
let result: UserAgentInfo | null = null;
|
||||
|
|
@ -20,7 +22,9 @@ export default function parseUserAgentMiddleware(request: express.Request, respo
|
|||
}
|
||||
|
||||
if (!result) {
|
||||
return next(new RequestException('Missing or invalid user agent', 400));
|
||||
// TODO - Error when invalid user agent is given!
|
||||
// return next(new RequestException('Missing or invalid user agent', 400));
|
||||
return next();
|
||||
}
|
||||
|
||||
request.pid = result.userPID;
|
||||
|
|
@ -85,10 +89,10 @@ function parse3DS(userAgent: string): UserAgentInfo | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
const [bossLibraryInfo, userInfo, firmwareVersion, unknownPart1, unknownPart2] = parts;
|
||||
const [bossLibraryInfo, userInfo, firmwareVersion, ctrSdkVersion, consoleModel] = parts;
|
||||
const [bossLibraryName, bossLibraryVersion] = bossLibraryInfo.split('-');
|
||||
const [bossLibraryVersionMajor, bossLibraryVersionMinor] = bossLibraryVersion.split('.');
|
||||
const [unknownUserInfo1Hex, unknownUserInfo2Hex] = userInfo.split('-');
|
||||
const [localFriendCodeSeedHex, friendCodeHex] = userInfo.split('-');
|
||||
const [firmwareMajor, firmwareMinor, firmwarePatchAndRegion] = firmwareVersion.split('.');
|
||||
|
||||
if (
|
||||
|
|
@ -98,38 +102,35 @@ function parse3DS(userAgent: string): UserAgentInfo | null {
|
|||
firmwareMajor !== '11' ||
|
||||
firmwareMinor !== '17' ||
|
||||
!['0-50J', '0-50U', '0-50E'].includes(firmwarePatchAndRegion) || // TODO - Make this more dynamic?
|
||||
unknownPart1 !== '62452' || // TODO - Is this right?
|
||||
!unknownPart2 || // TODO - Actually check this
|
||||
unknownUserInfo1Hex.length !== 16 ||
|
||||
unknownUserInfo2Hex.length !== 16
|
||||
ctrSdkVersion !== '62452' ||
|
||||
!Object.values(CTRSystemModel).includes(parseInt(consoleModel)) ||
|
||||
localFriendCodeSeedHex.length !== 16 ||
|
||||
friendCodeHex.length !== 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let unknownUserInfo1: number;
|
||||
let unknownUserInfo2: number;
|
||||
let localFriendCodeSeed: bigint;
|
||||
let friendCode: bigint;
|
||||
|
||||
// TODO - Parse this data? What is this?
|
||||
// TODO - Validate the LFCS, we currently don't store the value on the account server
|
||||
try {
|
||||
unknownUserInfo1 = parseInt(unknownUserInfo1Hex, 16);
|
||||
localFriendCodeSeed = BigInt('0x' + localFriendCodeSeedHex); // * Parse hex string to bigint
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO - Validate the friend code
|
||||
try {
|
||||
unknownUserInfo2 = parseInt(unknownUserInfo2Hex, 16);
|
||||
friendCode = BigInt('0x' + friendCodeHex); // * Parse hex string to bigint
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO - What is the upper 4 bytes?
|
||||
const localFriendCodeSeed = (unknownUserInfo1 & 0xFFFFFFFF); // * LFCS is the lower 4 bytes
|
||||
|
||||
// TODO - What is the upper 4 bytes?
|
||||
const userPID = (unknownUserInfo2 & 0xFFFFFFFF); // * PID is the lower 4 bytes
|
||||
const userPID = Number(friendCode & 0xFFFFFFFFn); // * PID is the lower 4 bytes
|
||||
|
||||
return {
|
||||
localFriendCodeSeed,
|
||||
userPID
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/models/cec-data.ts
Normal file
13
src/models/cec-data.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { ICECData, ICECDataMethods, CECDataModel } from '@/types/mongoose/cec-data';
|
||||
|
||||
const CECDataSchema = new mongoose.Schema<ICECData, CECDataModel, ICECDataMethods>({
|
||||
creator_pid: Number,
|
||||
game_id: Number,
|
||||
data: String,
|
||||
data_hash: String,
|
||||
size: Number,
|
||||
created: BigInt
|
||||
});
|
||||
|
||||
export const CECData = mongoose.model<ICECData, CECDataModel>('CECData', CECDataSchema);
|
||||
10
src/models/cec-slot.ts
Normal file
10
src/models/cec-slot.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { ICECSlot, ICECSlotMethods, CECSlotModel } from '@/types/mongoose/cec-slot';
|
||||
|
||||
const CECSlotSchema = new mongoose.Schema<ICECSlot, CECSlotModel, ICECSlotMethods>({
|
||||
creator_pid: Number,
|
||||
game_id: Number,
|
||||
latest_data_id: String
|
||||
});
|
||||
|
||||
export const CECSlot = mongoose.model<ICECSlot, CECSlotModel>('CECSlot', CECSlotSchema);
|
||||
|
|
@ -32,4 +32,4 @@ FileSchema.plugin(AutoIncrementID, {
|
|||
field: 'data_id'
|
||||
});
|
||||
|
||||
export const File: FileModel = mongoose.model<IFile, FileModel>('File', FileSchema);
|
||||
export const File = mongoose.model<IFile, FileModel>('File', FileSchema);
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ const TaskSchema = new mongoose.Schema<ITask, TaskModel, ITaskMethods>({
|
|||
updated: BigInt
|
||||
}, { id: false });
|
||||
|
||||
export const Task: TaskModel = mongoose.model<ITask, TaskModel>('Task', TaskSchema);
|
||||
export const Task = mongoose.model<ITask, TaskModel>('Task', TaskSchema);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import RequestException from '@/request-exception';
|
|||
import { LOG_INFO, LOG_SUCCESS } from '@/logger';
|
||||
import { config } from '@/config-manager';
|
||||
|
||||
//import parseUserAgentMiddleware from '@/middleware/parse-user-agent';
|
||||
import parseUserAgentMiddleware from '@/middleware/parse-user-agent';
|
||||
import authenticationMiddleware from '@/middleware/authentication';
|
||||
|
||||
import nppl from '@/services/nppl';
|
||||
|
|
@ -16,6 +16,7 @@ import npts from '@/services/npts';
|
|||
import npdi from '@/services/npdi';
|
||||
import npfl from '@/services/npfl';
|
||||
import npdl from '@/services/npdl';
|
||||
import spr from '@/services/spr';
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ app.use(express.urlencoded({
|
|||
extended: true
|
||||
}));
|
||||
|
||||
//app.use(parseUserAgentMiddleware);
|
||||
app.use(parseUserAgentMiddleware);
|
||||
app.use(authenticationMiddleware);
|
||||
|
||||
app.use(nppl);
|
||||
|
|
@ -34,6 +35,7 @@ app.use(npts);
|
|||
app.use(npdi);
|
||||
app.use(npfl);
|
||||
app.use(npdl);
|
||||
app.use(spr);
|
||||
|
||||
LOG_INFO('Creating 404 status handler');
|
||||
app.use((_request, response) => {
|
||||
|
|
@ -79,4 +81,4 @@ async function main(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
main().catch(console.error);
|
||||
|
|
|
|||
275
src/services/spr.ts
Normal file
275
src/services/spr.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import crypto from 'node:crypto';
|
||||
import express from 'express';
|
||||
import subdomain from 'express-subdomain';
|
||||
import Dicer from 'dicer';
|
||||
import { getDuplicateCECData, getRandomCECData } from '@/database';
|
||||
import { getFriends } from '@/util';
|
||||
import { CECData } from '@/models/cec-data';
|
||||
import { CECSlot } from '@/models/cec-slot';
|
||||
import { SendMode, SPRSlot } from '@/types/common/spr-slot';
|
||||
|
||||
const spr = express.Router();
|
||||
|
||||
function multipartParser(request: express.Request, response: express.Response, next: express.NextFunction): void {
|
||||
const RE_BOUNDARY = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i;
|
||||
const RE_FILE_NAME = /name="(.*)"/;
|
||||
|
||||
const contentType = request.header('content-type');
|
||||
|
||||
if (!contentType) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const boundary = RE_BOUNDARY.exec(contentType);
|
||||
|
||||
if (!boundary) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const dicer = new Dicer({ boundary: boundary[1] || boundary[2] });
|
||||
const files: Record<string, Buffer> = {};
|
||||
|
||||
dicer.on('part', (part: Dicer.PartStream) => {
|
||||
let fileBuffer = Buffer.alloc(0);
|
||||
let fileName = '';
|
||||
|
||||
part.on('header', header => {
|
||||
const contentDisposition = header['content-disposition' as keyof object];
|
||||
const regexResult = RE_FILE_NAME.exec(contentDisposition);
|
||||
|
||||
if (regexResult) {
|
||||
fileName = regexResult[1];
|
||||
}
|
||||
});
|
||||
|
||||
part.on('data', (data: Buffer | string) => {
|
||||
if (typeof data === 'string') {
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
|
||||
fileBuffer = Buffer.concat([fileBuffer, data]);
|
||||
});
|
||||
|
||||
part.on('end', () => {
|
||||
files[fileName] = fileBuffer;
|
||||
});
|
||||
});
|
||||
|
||||
dicer.on('finish', function () {
|
||||
request.files = files;
|
||||
return next();
|
||||
});
|
||||
|
||||
request.pipe(dicer);
|
||||
}
|
||||
|
||||
spr.post('/relay/0', multipartParser, async (request, response) => {
|
||||
if (!request.files) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.pid || !request.nexAccount) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Check that the account is a 3DS and isn't banned
|
||||
if (!request.nexAccount.friendCode || request.nexAccount.accessLevel < 0) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
const sprMetadataBuffer: Buffer | undefined = request.files['spr-meta'];
|
||||
|
||||
if (!sprMetadataBuffer) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
const sprSlots: SPRSlot[] = [];
|
||||
|
||||
// * Check spr-meta metadata headers
|
||||
const sprMetadata = sprMetadataBuffer.toString();
|
||||
const metadataHeaders = sprMetadata.split('\r\n'); // * Split header lines
|
||||
|
||||
if (metadataHeaders.length < 1) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < metadataHeaders.length; i++) {
|
||||
const metadataHeader = metadataHeaders[i];
|
||||
const [header, value] = metadataHeader.split(': '); // * Split header and value
|
||||
if (!header || !value) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Since the headers will always use the same pattern (first the slotsize, then the metadata for each slot),
|
||||
// * we can guarantee that i must match with the slot we are looking at except for 0, which will be the slotsize
|
||||
if (i === 0) {
|
||||
if (header !== 'slotsize') {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Validate slotsize
|
||||
let slotsize: number;
|
||||
try {
|
||||
slotsize = parseInt(value);
|
||||
} catch {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * We don't count the slotsize header itself in the slot count
|
||||
if (slotsize !== (metadataHeaders.length - 1)) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = value.split(','); // * Split the value to get the metadata
|
||||
|
||||
if (metadata.length !== 3) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
let sendMode: SendMode;
|
||||
let gameID: number;
|
||||
let size: number;
|
||||
try {
|
||||
sendMode = parseInt(metadata[0]);
|
||||
gameID = parseInt(metadata[1], 16);
|
||||
size = parseInt(metadata[2]);
|
||||
} catch {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
let data: Buffer = Buffer.alloc(0);
|
||||
if (size > 0 && sendMode !== SendMode.RecvOnly) {
|
||||
const slot = i.toString().padStart(2, '0');
|
||||
const slotData: Buffer | undefined = request.files['spr-slot' + slot];
|
||||
|
||||
if (!slotData) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slotData.length !== size) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Integrity checks for slot data. Every StreetPass message sent over relays has the following header:
|
||||
// * uint32 magic 0x6161
|
||||
// * uint32 size
|
||||
// * uint32 gameID
|
||||
// * uint32 unknown1
|
||||
// * uint32 unknown2
|
||||
// * This is then followed by a CecMessageHeader (see https://github.com/NarcolepticK/CECDocs/blob/master/Structs/CecMessageHeader.md)
|
||||
|
||||
// * Check that we at least have enough size for the StreetPass header
|
||||
if (slotData.length < 0x12) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slotData.readUInt32LE() !== 0x6161) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slotData.readUInt32LE(4) !== size) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (slotData.readUInt32LE(8) !== gameID) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
data = slotData;
|
||||
}
|
||||
|
||||
sprSlots.push({
|
||||
sendMode,
|
||||
gameID,
|
||||
size,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
const userFriends = await getFriends(request.pid);
|
||||
|
||||
let sprData: Buffer = Buffer.alloc(0);
|
||||
try {
|
||||
for (let i = 0; i < sprSlots.length; i++) {
|
||||
const sprSlot = sprSlots[i];
|
||||
const slot = String(i + 1).padStart(2, '0');
|
||||
|
||||
// * Upload slot data
|
||||
if (sprSlot.size > 0 && sprSlot.sendMode !== SendMode.RecvOnly) {
|
||||
const dataHash = crypto.createHash('sha256').update(sprSlot.data).digest('base64');
|
||||
let slotData = await getDuplicateCECData(request.pid, sprSlot.gameID);
|
||||
|
||||
if (!slotData || slotData.data_hash !== dataHash) {
|
||||
slotData = await CECData.create({
|
||||
creator_pid: request.pid,
|
||||
game_id: sprSlot.gameID,
|
||||
data: sprSlot.data.toString('base64'),
|
||||
data_hash: dataHash,
|
||||
size: sprSlot.size,
|
||||
created: BigInt(Date.now())
|
||||
});
|
||||
}
|
||||
|
||||
if (slotData.id) {
|
||||
await CECSlot.findOneAndUpdate({
|
||||
creator_pid: request.pid,
|
||||
game_id: slotData.game_id
|
||||
}, {latest_data_id: slotData.id}, {upsert: true});
|
||||
}
|
||||
}
|
||||
|
||||
if (!userFriends || userFriends.pids.length === 0) {
|
||||
continue; // * Nothing to receive
|
||||
}
|
||||
|
||||
// * Receive slot data
|
||||
if (sprSlot.sendMode !== SendMode.SendOnly) {
|
||||
const slotData = await getRandomCECData(userFriends.pids, sprSlot.gameID);
|
||||
|
||||
if (slotData) {
|
||||
sprData = Buffer.concat([sprData, Buffer.from(slotData.data, 'base64')]);
|
||||
sprSlot.size = slotData.size;
|
||||
} else {
|
||||
sprSlot.size = 0;
|
||||
}
|
||||
} else {
|
||||
sprSlot.size = 0;
|
||||
}
|
||||
|
||||
response.setHeader(`X-Spr-Slot${slot}-Result`, `${sprSlot.gameID.toString(16).toUpperCase().padStart(8, '0')},${sprSlot.sendMode},${sprSlot.size}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
response.send(sprData);
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(subdomain('service.spr.app', spr));
|
||||
|
||||
export default router;
|
||||
|
|
@ -28,6 +28,11 @@ export interface Config {
|
|||
port: number;
|
||||
api_key: string;
|
||||
};
|
||||
friends: {
|
||||
address: string;
|
||||
port: number;
|
||||
api_key: string;
|
||||
};
|
||||
};
|
||||
mongoose: {
|
||||
connection_string: string;
|
||||
|
|
@ -44,4 +49,4 @@ export interface Config {
|
|||
};
|
||||
disk_path: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
src/types/common/spr-slot.ts
Normal file
14
src/types/common/spr-slot.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// * Extracted from https://github.com/MrNbaYoh/libstreetpass/blob/master/include/cec/send_mode.hpp
|
||||
export enum SendMode {
|
||||
Exchange,
|
||||
RecvOnly,
|
||||
SendOnly,
|
||||
SendRecv
|
||||
}
|
||||
|
||||
export type SPRSlot = {
|
||||
sendMode: SendMode;
|
||||
gameID: number;
|
||||
size: number;
|
||||
data: Buffer;
|
||||
};
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
export enum CTRSystemModel {
|
||||
CTR, // * Nintendo 3DS
|
||||
SPR, // * Nintendo 3DS XL
|
||||
KTR, // * New Nintendo 3DS
|
||||
FTR, // * Nintendo 2DS
|
||||
RED, // * New Nintendo 3DS XL
|
||||
JAN // * New Nintendo 2DS XL
|
||||
}
|
||||
|
||||
export type UserAgentInfo = {
|
||||
deviceID?: number;
|
||||
localFriendCodeSeed?: number;
|
||||
localFriendCodeSeed?: bigint;
|
||||
userPID: number;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
7
src/types/express.d.ts
vendored
7
src/types/express.d.ts
vendored
|
|
@ -1,10 +1,11 @@
|
|||
import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
|
||||
import { GetNEXDataResponse } from '@pretendonetwork/grpc/account/get_nex_data_rpc';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
files?: Record<string, any>;
|
||||
pid: number;
|
||||
pnid: GetUserDataResponse | null;
|
||||
nexAccount: GetNEXDataResponse | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/types/mongoose/cec-data.ts
Normal file
18
src/types/mongoose/cec-data.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Model, HydratedDocument } from 'mongoose';
|
||||
|
||||
export interface ICECData {
|
||||
creator_pid: number;
|
||||
game_id: number;
|
||||
data: string;
|
||||
data_hash: string;
|
||||
size: number;
|
||||
created: bigint;
|
||||
}
|
||||
|
||||
export interface ICECDataMethods {}
|
||||
|
||||
interface ICECDataQueryHelpers {}
|
||||
|
||||
export interface CECDataModel extends Model<ICECData, ICECDataQueryHelpers, ICECDataMethods> {}
|
||||
|
||||
export type HydratedCECDataDocument = HydratedDocument<ICECData, ICECDataMethods>
|
||||
15
src/types/mongoose/cec-slot.ts
Normal file
15
src/types/mongoose/cec-slot.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Model, HydratedDocument } from 'mongoose';
|
||||
|
||||
export interface ICECSlot {
|
||||
creator_pid: number;
|
||||
game_id: number;
|
||||
latest_data_id: string;
|
||||
}
|
||||
|
||||
export interface ICECSlotMethods {}
|
||||
|
||||
interface ICECSlotQueryHelpers {}
|
||||
|
||||
export interface CECSlotModel extends Model<ICECSlot, ICECSlotQueryHelpers, ICECSlotMethods> {}
|
||||
|
||||
export type HydratedCECSlotDocument = HydratedDocument<ICECSlot, ICECSlotMethods>
|
||||
38
src/util.ts
38
src/util.ts
|
|
@ -5,7 +5,10 @@ import fs from 'fs-extra';
|
|||
import { createChannel, createClient, Metadata } from 'nice-grpc';
|
||||
import { GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
|
||||
import { AccountClient, AccountDefinition } from '@pretendonetwork/grpc/account/account_service';
|
||||
import { FriendsClient, FriendsDefinition } from '@pretendonetwork/grpc/friends/friends_service';
|
||||
import { GetNEXDataResponse } from '@pretendonetwork/grpc/account/get_nex_data_rpc';
|
||||
import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
|
||||
import { GetUserFriendPIDsResponse } from '@pretendonetwork/grpc/friends/get_user_friend_pids_rpc';
|
||||
import { config, disabledFeatures } from '@/config-manager';
|
||||
|
||||
let s3: S3;
|
||||
|
|
@ -25,6 +28,9 @@ if (!disabledFeatures.s3) {
|
|||
const gRPCAccountChannel = createChannel(`${config.grpc.account.address}:${config.grpc.account.port}`);
|
||||
const gRPCAccountClient: AccountClient = createClient(AccountDefinition, gRPCAccountChannel);
|
||||
|
||||
const gRPCFriendsChannel = createChannel(`${config.grpc.friends.address}:${config.grpc.friends.port}`);
|
||||
const gRPCFriendsClient: FriendsClient = createClient(FriendsDefinition, gRPCFriendsChannel);
|
||||
|
||||
const VALID_COUNTRIES = [
|
||||
'GB', 'US', 'IT', 'NL', 'DE', 'CA', 'FR', 'HU', 'CR',
|
||||
'AU', 'BR', 'RO', 'CL', 'MX', 'RU', 'ES', 'JP', 'CZ',
|
||||
|
|
@ -86,6 +92,21 @@ export async function getUserDataByPID(pid: number): Promise<GetUserDataResponse
|
|||
}
|
||||
}
|
||||
|
||||
export async function getNEXDataByPID(pid: number): Promise<GetNEXDataResponse | null> {
|
||||
try {
|
||||
return await gRPCAccountClient.getNEXData({
|
||||
pid: pid
|
||||
}, {
|
||||
metadata: Metadata({
|
||||
'X-API-Key': config.grpc.account.api_key
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// TODO - Handle error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDataByToken(token: string): Promise<GetUserDataResponse | null> {
|
||||
try {
|
||||
return await gRPCAccountClient.exchangeTokenForUserData({
|
||||
|
|
@ -102,6 +123,21 @@ export async function getUserDataByToken(token: string): Promise<GetUserDataResp
|
|||
}
|
||||
}
|
||||
|
||||
export async function getFriends(pid: number): Promise<GetUserFriendPIDsResponse | null> {
|
||||
try {
|
||||
return await gRPCFriendsClient.getUserFriendPIDs({
|
||||
pid: pid
|
||||
}, {
|
||||
metadata: Metadata({
|
||||
'X-API-Key': config.grpc.friends.api_key
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// TODO - Handle error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCDNFileStream(key: string): Promise<Readable | fs.ReadStream | null> {
|
||||
try {
|
||||
if (disabledFeatures.s3) {
|
||||
|
|
@ -152,4 +188,4 @@ export async function writeLocalCDNFile(key: string, data: Buffer): Promise<void
|
|||
|
||||
await fs.ensureDir(folder);
|
||||
await fs.writeFile(filePath, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user