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:
Daniel López Guimaraes 2024-04-27 00:03:32 +01:00
parent 16a762b972
commit 5f83d7a655
No known key found for this signature in database
GPG Key ID: 6AC74DE3DEF050E0
14 changed files with 490 additions and 41 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,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);
}

View File

@ -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
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,
size: Number,
created: BigInt,
updated: BigInt
}, { id: false });
export const CECData: CECDataModel = mongoose.model<ICECData, CECDataModel>('CECData', CECDataSchema);

View File

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

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,5 @@
export type UserAgentInfo = {
deviceID?: number;
localFriendCodeSeed?: number;
localFriendCodeSeed?: bigint;
userPID: number;
};
};

View File

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

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

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