Merge pull request #6 from DaniElectra/spr

This commit is contained in:
Jonathan Barrow 2024-05-04 09:58:33 -04:00 committed by GitHub
commit c7fc1bbcdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 553 additions and 47 deletions

66
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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
View 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
View 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);

View File

@ -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);

View File

@ -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);

View File

@ -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
View 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;

View File

@ -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;
};
}
}

View 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;
};

View File

@ -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;
};
};

View File

@ -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;
}
}
}
}

View 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>

View 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>

View File

@ -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);
}
}