mirror of
https://github.com/PretendoNetwork/BOSS.git
synced 2026-04-23 07:27:27 -05:00
feat: Introduce StreetPass Relays
StreetPass Relay allows 3DS users to exchange StreetPass data over the Internet. This is handled through SpotPass, and no user interaction is required. Also reenable the user agent checks as it's needed to identify the user using the PID present on it.
This commit is contained in:
parent
16a762b972
commit
5f83d7a655
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,9 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { CECData } from '@/models/cec-data';
|
||||
import { Task } from '@/models/task';
|
||||
import { File } from '@/models/file';
|
||||
import { config } from '@/config-manager';
|
||||
import { HydratedCECDataDocument, ICECData } from '@/types/mongoose/cec-data';
|
||||
import { HydratedTaskDocument, ITask } from '@/types/mongoose/task';
|
||||
import { HydratedFileDocument, IFile } from '@/types/mongoose/file';
|
||||
|
||||
|
|
@ -23,7 +25,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 +149,29 @@ 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();
|
||||
|
||||
const filter: mongoose.FilterQuery<ICECData> = {
|
||||
creator_pid: {
|
||||
$in: pids,
|
||||
},
|
||||
game_id: gameID
|
||||
};
|
||||
|
||||
const count = await CECData.countDocuments(filter);
|
||||
const rand = Math.floor(Math.random() * count);
|
||||
|
||||
return CECData.findOne<HydratedCECDataDocument>(filter).skip(rand);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import express from 'express';
|
||||
import RequestException from '@/request-exception';
|
||||
// import RequestException from '@/request-exception';
|
||||
import type { 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,34 @@ 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' ||
|
||||
!consoleModel || // TODO - Actually check this
|
||||
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?
|
||||
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,
|
||||
size: Number,
|
||||
created: BigInt,
|
||||
updated: BigInt
|
||||
}, { id: false });
|
||||
|
||||
export const CECData: CECDataModel = mongoose.model<ICECData, CECDataModel>('CECData', CECDataSchema);
|
||||
|
|
@ -8,14 +8,15 @@ 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 authenticationMiddleware from '@/middleware/authentication';
|
||||
import parseUserAgentMiddleware from '@/middleware/parse-user-agent';
|
||||
// import authenticationMiddleware from '@/middleware/authentication';
|
||||
|
||||
import nppl from '@/services/nppl';
|
||||
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,14 +27,15 @@ app.use(express.urlencoded({
|
|||
extended: true
|
||||
}));
|
||||
|
||||
//app.use(parseUserAgentMiddleware);
|
||||
app.use(authenticationMiddleware);
|
||||
app.use(parseUserAgentMiddleware);
|
||||
// app.use(authenticationMiddleware);
|
||||
|
||||
app.use(nppl);
|
||||
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);
|
||||
|
|
|
|||
263
src/services/spr.ts
Normal file
263
src/services/spr.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import express from 'express';
|
||||
import subdomain from 'express-subdomain';
|
||||
import Dicer from 'dicer';
|
||||
import { getDuplicateCECData, getRandomCECData } from '@/database';
|
||||
import { getNEXDataByPID, getFriends } from '@/util';
|
||||
import { CECData } from '@/models/cec-data';
|
||||
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 (data instanceof String) {
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
|
||||
fileBuffer = Buffer.concat([fileBuffer, data as Buffer]);
|
||||
});
|
||||
|
||||
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) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Check that the account is a 3DS and isn't banned
|
||||
const accountData = await getNEXDataByPID(request.pid);
|
||||
if (!accountData) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accountData.friendCode || accountData.accessLevel < 0) {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
const sprMetadataBuffer: Buffer | undefined = request.files['spr-meta'];
|
||||
if (typeof sprMetadataBuffer === 'undefined') {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
let 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 field = metadataHeader.split(': '); // * Split header and value
|
||||
if (field.length !== 2) {
|
||||
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 (field[0] !== 'slotsize') {
|
||||
response.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// * Validate slotsize
|
||||
let slotsize: number;
|
||||
try {
|
||||
slotsize = parseInt(field[1]);
|
||||
} 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 = field[1].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 (typeof slotData === 'undefined') {
|
||||
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(0) !== 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 = sprSlots.concat({
|
||||
sendMode,
|
||||
gameID,
|
||||
size,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
const userFriends = await getFriends(request.pid);
|
||||
|
||||
let sprData: Buffer = Buffer.alloc(0);
|
||||
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 slotData = await getDuplicateCECData(request.pid, sprSlot.gameID);
|
||||
if (slotData) {
|
||||
slotData.data = sprSlot.data.toString('base64');
|
||||
slotData.size = sprSlot.size;
|
||||
slotData.updated = BigInt(Date.now());
|
||||
await slotData.save();
|
||||
} else {
|
||||
await CECData.create({
|
||||
creator_pid: request.pid,
|
||||
game_id: sprSlot.gameID,
|
||||
data: sprSlot.data.toString('base64'),
|
||||
size: sprSlot.size,
|
||||
created: BigInt(Date.now()),
|
||||
updated: BigInt(Date.now())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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,5 @@
|
|||
export type UserAgentInfo = {
|
||||
deviceID?: number;
|
||||
localFriendCodeSeed?: number;
|
||||
localFriendCodeSeed?: bigint;
|
||||
userPID: number;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
3
src/types/express.d.ts
vendored
3
src/types/express.d.ts
vendored
|
|
@ -3,8 +3,9 @@ import { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data
|
|||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
files?: Record<string, any>;
|
||||
pid: number;
|
||||
pnid: GetUserDataResponse | 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;
|
||||
size: number;
|
||||
created: bigint;
|
||||
updated: bigint;
|
||||
}
|
||||
|
||||
export interface ICECDataMethods {}
|
||||
|
||||
interface ICECDataQueryHelpers {}
|
||||
|
||||
export interface CECDataModel extends Model<ICECData, ICECDataQueryHelpers, ICECDataMethods> {}
|
||||
|
||||
export type HydratedCECDataDocument = HydratedDocument<ICECData, ICECDataMethods>
|
||||
37
src/util.ts
37
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;
|
||||
|
|
@ -24,6 +27,8 @@ 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',
|
||||
|
|
@ -86,6 +91,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 +122,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 +187,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