feat: implement v2 grpc servers

adds support for the new v2 gRPC servers while maintaining the old ones for legacy clients
This commit is contained in:
Jonathan Barrow 2025-06-17 16:55:38 -04:00
parent 84922a5c51
commit eac5ec9742
No known key found for this signature in database
GPG Key ID: 2A7DAA6DED5A77E5
42 changed files with 1194 additions and 73 deletions

3
.gitignore vendored
View File

@ -61,4 +61,5 @@ typings/
config.json
certs
cdn
dist
dist
.DS_Store

32
package-lock.json generated
View File

@ -12,7 +12,7 @@
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^1.0.5",
"@pretendonetwork/grpc": "2.1.1",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",
@ -2915,6 +2915,12 @@
"version": "2.7.0",
"license": "0BSD"
},
"node_modules/@bufbuild/protobuf": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.5.2.tgz",
"integrity": "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@eslint-community/eslint-plugin-eslint-comments": {
"version": "4.4.1",
"dev": true,
@ -3565,11 +3571,27 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "1.0.5",
"license": "ISC",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.1.1.tgz",
"integrity": "sha512-SEfeG9zqRh8Iaij7cPSpnC3s5bQ5oJhCXO0tKIMpirO6aQIGit8sNrABDfGihSXMVK9q+R8gHzODapIQQV4V6Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"long": "^5.2.1",
"protobufjs": "^7.2.3"
"@bufbuild/protobuf": "^2.2.2",
"nice-grpc-common": "^2.0.2",
"typescript": "^5.7.2"
}
},
"node_modules/@pretendonetwork/grpc/node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@protobufjs/aspromise": {

View File

@ -27,7 +27,7 @@
"@aws-sdk/client-s3": "^3.657.0",
"@aws-sdk/client-ses": "^3.515.0",
"@inquirer/prompts": "^7.2.0",
"@pretendonetwork/grpc": "^1.0.5",
"@pretendonetwork/grpc": "2.1.1",
"bcrypt": "^5.0.0",
"buffer-crc32": "^0.2.13",
"colors": "^1.4.0",

View File

@ -1,13 +0,0 @@
import { getUserData } from '@/services/grpc/account/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/get-nex-password';
import { getNEXData } from '@/services/grpc/account/get-nex-data';
import { updatePNIDPermissions } from '@/services/grpc/account/update-pnid-permissions';
import { exchangeTokenForUserData } from '@/services/grpc/account/exchange-token-for-user-data';
export const accountServiceImplementation = {
getUserData,
getNEXPassword,
getNEXData,
updatePNIDPermissions,
exchangeTokenForUserData
};

View File

@ -0,0 +1,62 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import type { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/exchange_token_for_user_data';
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<GetUserDataResponse> {
if (!request.token.trim()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
const pnid = await getPNIDByTokenAuth(request.token);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
return {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
}
};
}

View File

@ -0,0 +1,60 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { GetUserDataRequest, GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
export async function getUserData(request: GetUserDataRequest): Promise<GetUserDataResponse> {
const pnid = await getPNIDByPID(request.pid);
if (!pnid) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No PNID found'
);
}
return {
deleted: pnid.deleted,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
creationDate: pnid.creation_date,
birthdate: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
language: pnid.language,
emailAddress: pnid.email.address,
tierName: pnid.connections.stripe.tier_name,
permissions: {
bannedAllPermanently: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY),
bannedAllTemporarily: pnid.hasPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY),
betaAccess: pnid.hasPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS),
accessAdminPanel: pnid.hasPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL),
createServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS),
modifyServerConfigs: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS),
deployServer: pnid.hasPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER),
modifyPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS),
modifyNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS),
modifyConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES),
banPnids: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS),
banNexAccounts: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS),
banConsoles: pnid.hasPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES),
moderateMiiverse: pnid.hasPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE),
createApiKeys: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS),
createBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS),
updateBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS),
deleteBossTasks: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS),
uploadBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES),
updateBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES),
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
}
};
}

View File

@ -0,0 +1,13 @@
import { getUserData } from '@/services/grpc/account/v1/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/v1/get-nex-password';
import { getNEXData } from '@/services/grpc/account/v1/get-nex-data';
import { updatePNIDPermissions } from '@/services/grpc/account/v1/update-pnid-permissions';
import { exchangeTokenForUserData } from '@/services/grpc/account/v1/exchange-token-for-user-data';
export const accountServiceImplementationV1 = {
getUserData,
getNEXPassword,
getNEXData,
updatePNIDPermissions,
exchangeTokenForUserData
};

View File

@ -2,7 +2,7 @@ import { Status, ServerError } from 'nice-grpc';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import type { UpdatePNIDPermissionsRequest } from '@pretendonetwork/grpc/account/update_pnid_permissions';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<Empty> {
const pnid = await getPNIDByPID(request.pid);

View File

@ -0,0 +1,16 @@
import { Status, ServerError } from 'nice-grpc';
import { config } from '@/config-manager';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
export async function* apiKeyMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response>,
context: CallContext
): AsyncGenerator<Response, Response | void, undefined> {
const apiKey = context.metadata.get('X-API-Key');
if (!apiKey || apiKey !== config.grpc.master_api_keys.account) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid API key');
}
return yield* call.next(call.request, context);
}

View File

@ -3,8 +3,8 @@ import { getPNIDByTokenAuth } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import type { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/exchange_token_for_user_data';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/v2/get_user_data_rpc';
import type { ExchangeTokenForUserDataRequest } from '@pretendonetwork/grpc/account/v2/exchange_token_for_user_data';
export async function exchangeTokenForUserData(request: ExchangeTokenForUserDataRequest): Promise<GetUserDataResponse> {
if (!request.token.trim()) {
@ -71,6 +71,6 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
devices
linkedDevices: devices
};
}

View File

@ -0,0 +1,23 @@
import { Status, ServerError } from 'nice-grpc';
import { NEXAccount } from '@/models/nex-account';
import type { GetNEXDataRequest, GetNEXDataResponse, DeepPartial } from '@pretendonetwork/grpc/account/v2/get_nex_data_rpc';
export async function getNEXData(request: GetNEXDataRequest): Promise<DeepPartial<GetNEXDataResponse>> {
const nexAccount = await NEXAccount.findOne({ pid: request.pid });
if (!nexAccount) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No NEX account found'
);
}
return {
pid: nexAccount.pid,
password: nexAccount.password,
owningPid: nexAccount.owning_pid,
accessLevel: nexAccount.access_level,
serverAccessLevel: nexAccount.server_access_level,
friendCode: nexAccount.friend_code
};
}

View File

@ -0,0 +1,18 @@
import { Status, ServerError } from 'nice-grpc';
import { NEXAccount } from '@/models/nex-account';
import type { GetNEXPasswordRequest, GetNEXPasswordResponse, DeepPartial } from '@pretendonetwork/grpc/account/v2/get_nex_password_rpc';
export async function getNEXPassword(request: GetNEXPasswordRequest): Promise<DeepPartial<GetNEXPasswordResponse>> {
const nexAccount = await NEXAccount.findOne({ pid: request.pid });
if (!nexAccount) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No NEX account found'
);
}
return {
password: nexAccount.password
};
}

View File

@ -3,7 +3,7 @@ import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
import type { GetUserDataRequest, GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
import type { GetUserDataRequest, GetUserDataResponse } from '@pretendonetwork/grpc/account/v2/get_user_data_rpc';
export async function getUserData(request: GetUserDataRequest): Promise<GetUserDataResponse> {
const pnid = await getPNIDByPID(request.pid);
@ -69,6 +69,6 @@ export async function getUserData(request: GetUserDataRequest): Promise<GetUserD
deleteBossFiles: pnid.hasPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES),
updatePnidPermissions: pnid.hasPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS)
},
devices
linkedDevices: devices
};
}

View File

@ -0,0 +1,13 @@
import { getUserData } from '@/services/grpc/account/v2/get-user-data';
import { getNEXPassword } from '@/services/grpc/account/v2/get-nex-password';
import { getNEXData } from '@/services/grpc/account/v2/get-nex-data';
import { updatePNIDPermissions } from '@/services/grpc/account/v2/update-pnid-permissions';
import { exchangeTokenForUserData } from '@/services/grpc/account/v2/exchange-token-for-user-data';
export const accountServiceImplementationV2 = {
getUserData,
getNEXPassword,
getNEXData,
updatePNIDPermissions,
exchangeTokenForUserData
};

View File

@ -0,0 +1,159 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByPID } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import type { UpdatePNIDPermissionsRequest } from '@pretendonetwork/grpc/account/v2/update_pnid_permissions';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
export async function updatePNIDPermissions(request: UpdatePNIDPermissionsRequest): Promise<Empty> {
const pnid = await getPNIDByPID(request.pid);
if (!pnid) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'No PNID found'
);
}
if (!request.permissions) {
throw new ServerError(
Status.INVALID_ARGUMENT,
'Permissions flags not found'
);
}
if (request.permissions.bannedAllPermanently === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY);
} else if (request.permissions.bannedAllPermanently === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_PERMANENTLY);
}
if (request.permissions.bannedAllTemporarily === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY);
} else if (request.permissions.bannedAllTemporarily === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BANNED_ALL_TEMPORARILY);
}
if (request.permissions.betaAccess === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS);
} else if (request.permissions.betaAccess === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BETA_ACCESS);
}
if (request.permissions.accessAdminPanel === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL);
} else if (request.permissions.accessAdminPanel === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.ACCESS_ADMIN_PANEL);
}
if (request.permissions.createServerConfigs === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS);
} else if (request.permissions.createServerConfigs === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_SERVER_CONFIGS);
}
if (request.permissions.modifyServerConfigs === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS);
} else if (request.permissions.modifyServerConfigs === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_SERVER_CONFIGS);
}
if (request.permissions.deployServer === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER);
} else if (request.permissions.deployServer === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DEPLOY_SERVER);
}
if (request.permissions.modifyPnids === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS);
} else if (request.permissions.modifyPnids === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_PNIDS);
}
if (request.permissions.modifyNexAccounts === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS);
} else if (request.permissions.modifyNexAccounts === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_NEX_ACCOUNTS);
}
if (request.permissions.modifyConsoles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES);
} else if (request.permissions.modifyConsoles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODIFY_CONSOLES);
}
if (request.permissions.banPnids === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS);
} else if (request.permissions.banPnids === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_PNIDS);
}
if (request.permissions.banNexAccounts === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS);
} else if (request.permissions.banNexAccounts === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_NEX_ACCOUNTS);
}
if (request.permissions.banConsoles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES);
} else if (request.permissions.banConsoles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.BAN_CONSOLES);
}
if (request.permissions.moderateMiiverse === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE);
} else if (request.permissions.moderateMiiverse === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.MODERATE_MIIVERSE);
}
if (request.permissions.createApiKeys === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS);
} else if (request.permissions.createApiKeys === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_API_KEYS);
}
if (request.permissions.createBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS);
} else if (request.permissions.createBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.CREATE_BOSS_TASKS);
}
if (request.permissions.updateBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS);
} else if (request.permissions.updateBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_TASKS);
}
if (request.permissions.deleteBossTasks === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS);
} else if (request.permissions.deleteBossTasks === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_TASKS);
}
if (request.permissions.uploadBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES);
} else if (request.permissions.uploadBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPLOAD_BOSS_FILES);
}
if (request.permissions.updateBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES);
} else if (request.permissions.updateBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_BOSS_FILES);
}
if (request.permissions.deleteBossFiles === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES);
} else if (request.permissions.deleteBossFiles === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.DELETE_BOSS_FILES);
}
if (request.permissions.updatePnidPermissions === true) {
await pnid.addPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS);
} else if (request.permissions.updatePnidPermissions === false) {
await pnid.clearPermission(PNID_PERMISSION_FLAGS.UPDATE_PNID_PERMISSIONS);
}
await pnid.save();
return {};
}

View File

@ -1,19 +0,0 @@
import { register } from '@/services/grpc/api/register';
import { login } from '@/services/grpc/api/login';
import { getUserData } from '@/services/grpc/api/get-user-data';
import { updateUserData } from '@/services/grpc/api/update-user-data';
import { forgotPassword } from '@/services/grpc/api/forgot-password';
import { resetPassword } from '@/services/grpc/api/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/set-discord-connection-data';
import { setStripeConnectionData } from '@/services/grpc/api/set-stripe-connection-data';
export const apiServiceImplementation = {
register,
login,
getUserData,
updateUserData,
forgotPassword,
resetPassword,
setDiscordConnectionData,
setStripeConnectionData
};

View File

@ -3,7 +3,7 @@ import validator from 'validator';
import { getPNIDByEmailAddress, getPNIDByUsername } from '@/database';
import { sendForgotPasswordEmail } from '@/util';
import type { ForgotPasswordRequest } from '@pretendonetwork/grpc/api/forgot_password_rpc';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function forgotPassword(request: ForgotPasswordRequest): Promise<Empty> {

View File

@ -1,8 +1,8 @@
import { config } from '@/config-manager';
import type { CallContext } from 'nice-grpc';
import type { GetUserDataResponse, DeepPartial } from '@pretendonetwork/grpc/api/get_user_data_rpc';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/authentication-middleware';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function getUserData(_request: Empty, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null

View File

@ -0,0 +1,19 @@
import { register } from '@/services/grpc/api/v1/register';
import { login } from '@/services/grpc/api/v1/login';
import { getUserData } from '@/services/grpc/api/v1/get-user-data';
import { updateUserData } from '@/services/grpc/api/v1/update-user-data';
import { forgotPassword } from '@/services/grpc/api/v1/forgot-password';
import { resetPassword } from '@/services/grpc/api/v1/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/v1/set-discord-connection-data';
import { setStripeConnectionData } from '@/services/grpc/api/v1/set-stripe-connection-data';
export const apiServiceImplementationV1 = {
register,
login,
getUserData,
updateUserData,
forgotPassword,
resetPassword,
setDiscordConnectionData,
setStripeConnectionData
};

View File

@ -3,7 +3,7 @@ import { Status, ServerError } from 'nice-grpc';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import { getPNIDByPID } from '@/database';
import type { ResetPasswordRequest } from '@pretendonetwork/grpc/api/reset_password_rpc';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { Token } from '@/types/common/token';
// * This sucks

View File

@ -1,8 +1,8 @@
import { Status, ServerError } from 'nice-grpc';
import type { CallContext } from 'nice-grpc';
import type { SetDiscordConnectionDataRequest } from '@pretendonetwork/grpc/api/set_discord_connection_data_rpc';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/authentication-middleware';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function setDiscordConnectionData(request: SetDiscordConnectionDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<Empty> {
// * This is asserted in authentication-middleware, we know this is never null

View File

@ -2,8 +2,8 @@ import { Status, ServerError } from 'nice-grpc';
import { PNID } from '@/models/pnid';
import type { CallContext } from 'nice-grpc';
import type { SetStripeConnectionDataRequest } from '@pretendonetwork/grpc/api/set_stripe_connection_data_rpc';
import type { Empty } from '@pretendonetwork/grpc/api/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/authentication-middleware';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
type StripeMongoUpdateScheme = {
'access_level'?: number;
@ -28,16 +28,6 @@ export async function setStripeConnectionData(request: SetStripeConnectionDataRe
updateData['connections.stripe.customer_id'] = request.customerId;
}
// * These checks allow for null/0 values in order to reset data if needed
if (request.accessLevel !== undefined) {
updateData.access_level = request.accessLevel;
}
if (request.serverAccessLevel !== undefined) {
updateData.server_access_level = request.serverAccessLevel;
}
if (request.subscriptionId !== undefined) {
updateData['connections.stripe.subscription_id'] = request.subscriptionId;
}

View File

@ -2,7 +2,7 @@ import { config } from '@/config-manager';
import type { CallContext } from 'nice-grpc';
import type { UpdateUserDataRequest, DeepPartial } from '@pretendonetwork/grpc/api/update_user_data_rpc';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/api/get_user_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/authentication-middleware';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function updateUserData(_request: UpdateUserDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null

View File

@ -0,0 +1,16 @@
import { Status, ServerError } from 'nice-grpc';
import { config } from '@/config-manager';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
export async function* apiKeyMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response>,
context: CallContext
): AsyncGenerator<Response, Response | void, undefined> {
const apiKey = context.metadata.get('X-API-Key');
if (!apiKey || apiKey !== config.grpc.master_api_keys.api) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid API key');
}
return yield* call.next(call.request, context);
}

View File

@ -0,0 +1,56 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
// * These paths require that a token be present
const TOKEN_REQUIRED_PATHS = [
'/api.v2.ApiService/GetUserData',
'/api.v2.ApiService/UpdateUserData',
'/api.v2.ApiService/ResetPassword', // * This paths token is not an authentication token, it is a password reset token
'/api.v2.ApiService/SetDiscordConnectionData',
'/api.v2.ApiService/SetStripeConnectionData',
'/api.v2.ApiService/RemoveConnection'
];
export type AuthenticationCallContextExt = {
pnid: HydratedPNIDDocument | null;
};
export async function* authenticationMiddleware<Request, Response>(
call: ServerMiddlewareCall<Request, Response, AuthenticationCallContextExt>,
context: CallContext
): AsyncGenerator<Response, Response | void, undefined> {
const token = context.metadata.get('X-Token')?.trim();
if (!token && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid authentication token');
}
try {
let pnid = null;
if (token) {
pnid = await getPNIDByTokenAuth(token);
}
if (!pnid && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {
throw new ServerError(Status.UNAUTHENTICATED, 'Missing or invalid authentication token');
}
return yield* call.next(call.request, {
...context,
pnid
});
} catch (error) {
let message = 'Unknown server error';
console.log(error);
if (error instanceof Error) {
message = error.message;
}
throw new ServerError(Status.INVALID_ARGUMENT, message);
}
}

View File

@ -0,0 +1,29 @@
import { Status, ServerError } from 'nice-grpc';
import validator from 'validator';
import { getPNIDByEmailAddress, getPNIDByUsername } from '@/database';
import { sendForgotPasswordEmail } from '@/util';
import type { ForgotPasswordRequest } from '@pretendonetwork/grpc/api/v2/forgot_password_rpc';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function forgotPassword(request: ForgotPasswordRequest): Promise<Empty> {
const input = request.emailAddressOrUsername.trim();
if (!input) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing input');
}
let pnid: HydratedPNIDDocument | null;
if (validator.isEmail(input)) {
pnid = await getPNIDByEmailAddress(input);
} else {
pnid = await getPNIDByUsername(input);
}
if (pnid) {
await sendForgotPasswordEmail(pnid);
}
return {};
}

View File

@ -0,0 +1,45 @@
import { config } from '@/config-manager';
import type { CallContext } from 'nice-grpc';
import type { GetUserDataResponse, DeepPartial } from '@pretendonetwork/grpc/api/v2/get_user_data_rpc';
import type { Empty } from '@pretendonetwork/grpc/google/protobuf/empty';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function getUserData(_request: Empty, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
return {
deleted: pnid.deleted,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
birthday: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
connections: {
discord: {
id: pnid.connections.discord.id
},
stripe: {
customerId: pnid.connections.stripe.customer_id,
subscriptionId: pnid.connections.stripe.subscription_id,
priceId: pnid.connections.stripe.price_id,
tierLevel: pnid.connections.stripe.tier_level,
tierName: pnid.connections.stripe.tier_name,
latestWebhookTimestamp: BigInt(pnid.connections.stripe.latest_webhook_timestamp ?? 0)
}
},
marketingFlag: pnid.flags.marketing
};
}

View File

@ -0,0 +1,19 @@
import { register } from '@/services/grpc/api/v2/register';
import { login } from '@/services/grpc/api/v2/login';
import { getUserData } from '@/services/grpc/api/v2/get-user-data';
import { updateUserData } from '@/services/grpc/api/v2/update-user-data';
import { forgotPassword } from '@/services/grpc/api/v2/forgot-password';
import { resetPassword } from '@/services/grpc/api/v2/reset-password';
import { setDiscordConnectionData } from '@/services/grpc/api/v2/set-discord-connection-data';
import { setStripeConnectionData } from '@/services/grpc/api/v2/set-stripe-connection-data';
export const apiServiceImplementationV2 = {
register,
login,
getUserData,
updateUserData,
forgotPassword,
resetPassword,
setDiscordConnectionData,
setStripeConnectionData
};

View File

@ -0,0 +1,89 @@
import { Status, ServerError } from 'nice-grpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { config } from '@/config-manager';
import type { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/v2/login_rpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function login(request: LoginRequest): Promise<DeepPartial<LoginResponse>> {
const grantType = request.grantType?.trim();
const username = request.username?.trim();
const password = request.password?.trim();
const refreshToken = request.refreshToken?.trim();
if (!['password', 'refresh_token'].includes(grantType)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
}
if (grantType === 'password' && !username) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
}
if (grantType === 'password' && !password) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
}
if (grantType === 'refresh_token' && !refreshToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
let pnid: HydratedPNIDDocument | null;
if (grantType === 'password') {
pnid = await getPNIDByUsername(username!); // * We know username will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
}
const hashedPassword = nintendoPasswordHash(password!, pnid.pid); // * We know password will never be null here
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
}
if (pnid.deleted) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: newRefreshToken
};
}

View File

@ -0,0 +1,264 @@
import crypto from 'node:crypto';
import { Status, ServerError } from 'nice-grpc';
import emailvalidator from 'email-validator';
import bcrypt from 'bcrypt';
import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { config, disabledFeatures } from '@/config-manager';
import type { LoginResponse } from '@pretendonetwork/grpc/api/v2/login_rpc';
import type { RegisterRequest, DeepPartial } from '@pretendonetwork/grpc/api/v2/register_rpc';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
const PNID_PUNCTUATION_END_REGEX = /[_\-.]$/;
const PNID_PUNCTUATION_DUPLICATE_REGEX = /[_\-.]{2,}/;
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
const DEFAULT_MII_DATA = Buffer.from('AwAAQOlVognnx0GC2/uogAOzuI0n2QAAAEBEAGUAZgBhAHUAbAB0AAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGm9', 'base64');
export async function register(request: RegisterRequest): Promise<DeepPartial<LoginResponse>> {
const email = request.email?.trim();
const username = request.username?.trim();
const miiName = request.miiName?.trim();
const password = request.password?.trim();
const passwordConfirm = request.passwordConfirm?.trim();
const captchaResponse = request.captchaResponse?.trim();
// * Only validate the captcha if that's enabled
if (!disabledFeatures.captcha) {
if (!captchaResponse) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must fill in captcha');
}
const captchaVerify = await hcaptcha.verify(config.hcaptcha.secret, captchaResponse);
if (!captchaVerify.success) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Captcha verification failed');
}
}
if (!email) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter an email address');
}
if (!emailvalidator.validate(email)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid email address');
}
if (!username) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a username');
}
if (username.length < 6) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username is too short');
}
if (username.length > 16) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username is too long');
}
if (!PNID_VALID_CHARACTERS_REGEX.test(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username contains invalid characters');
}
if (PNID_PUNCTUATION_START_REGEX.test(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username cannot begin with punctuation characters');
}
if (PNID_PUNCTUATION_END_REGEX.test(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Username cannot end with punctuation characters');
}
if (PNID_PUNCTUATION_DUPLICATE_REGEX.test(username)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Two or more punctuation characters cannot be used in a row');
}
const userExists = await doesPNIDExist(username);
if (userExists) {
throw new ServerError(Status.INVALID_ARGUMENT, 'PNID already in use');
}
if (!miiName) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a Mii name');
}
const miiNameBuffer = Buffer.from(miiName, 'utf16le'); // * UTF8 to UTF16
if (miiNameBuffer.length > 0x14) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Mii name too long');
}
if (!password) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a password');
}
if (password.length < 6 || password.length > 16) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must be between 6 and 16 characters long');
}
if (password.toLowerCase() === username.toLowerCase()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password cannot be the same as username');
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must have combination of letters, numbers, and/or punctuation characters');
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password may not have 3 repeating characters');
}
if (password !== passwordConfirm) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Passwords do not match');
}
const mii = new Mii(DEFAULT_MII_DATA);
mii.miiName = miiName;
const creationDate = moment().format('YYYY-MM-DDTHH:MM:SS');
let pnid: HydratedPNIDDocument;
let nexAccount: HydratedNEXAccountDocument;
const session = await databaseConnection().startSession();
await session.startTransaction();
try {
// * PNIDs can only be registered from a Wii U
// * So assume website users are WiiU NEX accounts
nexAccount = new NEXAccount({
device_type: 'wiiu'
});
await nexAccount.generatePID();
await nexAccount.generatePassword();
// * Quick hack to get the PIDs to match
// TODO - Change this maybe?
// * NN with a NNID will always use the NNID PID
// * even if the provided NEX PID is different
// * To fix this we make them the same PID
nexAccount.owning_pid = nexAccount.pid;
await nexAccount.save({ session });
const primaryPasswordHash = nintendoPasswordHash(password, nexAccount.pid);
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid = new PNID({
pid: nexAccount.pid,
creation_date: creationDate,
updated: creationDate,
username: username,
usernameLower: username.toLowerCase(),
password: passwordHash,
birthdate: '1990-01-01', // TODO - Change this
gender: 'M', // TODO - Change this
country: 'US', // TODO - Change this
language: 'en', // TODO - Change this
email: {
address: email.toLowerCase(),
primary: true, // TODO - Change this
parent: true, // TODO - Change this
reachable: false, // TODO - Change this
validated: false, // TODO - Change this
id: crypto.randomBytes(4).readUInt32LE()
},
region: 0x310B0000, // TODO - Change this
timezone: {
name: 'America/New_York', // TODO - Change this
offset: -14400 // TODO - Change this
},
mii: {
name: miiName,
primary: true, // TODO - Change this
data: mii.encode().toString('base64'),
id: crypto.randomBytes(4).readUInt32LE(),
hash: crypto.randomBytes(7).toString('hex'),
image_url: '', // * deprecated, will be removed in the future
image_id: crypto.randomBytes(4).readUInt32LE()
},
flags: {
active: true, // TODO - Change this
marketing: true, // TODO - Change this
off_device: true // TODO - Change this
},
identification: {
email_code: 1, // * will be overwritten before saving
email_token: '' // * will be overwritten before saving
}
});
await pnid.generateEmailValidationCode();
await pnid.generateEmailValidationToken();
await pnid.generateMiiImages();
await pnid.save({ session });
await session.commitTransaction();
} catch (error) {
let message = 'Unknown Mongo error';
if (error instanceof Error) {
message = error.message;
}
LOG_ERROR(`[gRPC] /api.API/Register: ${message}`);
await session.abortTransaction();
throw new ServerError(Status.INVALID_ARGUMENT, message);
} finally {
// * This runs regardless of failure
// * Returning on catch will not prevent this from running
await session.endSession();
}
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: refreshToken
};
}

View File

@ -0,0 +1,77 @@
import bcrypt from 'bcrypt';
import { Status, ServerError } from 'nice-grpc';
import { decryptToken, unpackToken, nintendoPasswordHash } from '@/util';
import { getPNIDByPID } from '@/database';
import type { ResetPasswordRequest, ResetPasswordResponse } from '@pretendonetwork/grpc/api/v2/reset_password_rpc';
import type { Token } from '@/types/common/token';
// * This sucks
const PASSWORD_WORD_OR_NUMBER_REGEX = /(?=.*[a-zA-Z])(?=.*\d).*/;
const PASSWORD_WORD_OR_PUNCTUATION_REGEX = /(?=.*[a-zA-Z])(?=.*[_\-.]).*/;
const PASSWORD_NUMBER_OR_PUNCTUATION_REGEX = /(?=.*\d)(?=.*[_\-.]).*/;
const PASSWORD_REPEATED_CHARACTER_REGEX = /(.)\1\1/;
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
const password = request.password.trim();
const passwordConfirm = request.passwordConfirm.trim();
const token = request.token.trim();
if (!token) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Missing token');
}
let unpackedToken: Token;
try {
const decryptedToken = await decryptToken(Buffer.from(token, 'base64'));
unpackedToken = unpackToken(decryptedToken);
} catch {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
if (unpackedToken.expire_time < Date.now()) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Token expired');
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token. No user found');
}
if (!password) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Must enter a password');
}
if (password.length < 6) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is too short');
}
if (password.length > 16) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is too long');
}
if (password.toLowerCase() === pnid.usernameLower) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password cannot be the same as username');
}
if (!PASSWORD_WORD_OR_NUMBER_REGEX.test(password) && !PASSWORD_WORD_OR_PUNCTUATION_REGEX.test(password) && !PASSWORD_NUMBER_OR_PUNCTUATION_REGEX.test(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password must have combination of letters, numbers, and/or punctuation characters');
}
if (PASSWORD_REPEATED_CHARACTER_REGEX.test(password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password may not have 3 repeating characters');
}
if (password !== passwordConfirm) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Passwords do not match');
}
const primaryPasswordHash = nintendoPasswordHash(password, pnid.pid);
const passwordHash = await bcrypt.hash(primaryPasswordHash, 10);
pnid.password = passwordHash;
await pnid.save();
return {};
}

View File

@ -0,0 +1,25 @@
import { Status, ServerError } from 'nice-grpc';
import type { CallContext } from 'nice-grpc';
import type { SetDiscordConnectionDataRequest, SetDiscordConnectionDataResponse } from '@pretendonetwork/grpc/api/v2/set_discord_connection_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function setDiscordConnectionData(request: SetDiscordConnectionDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<SetDiscordConnectionDataResponse> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
try {
pnid.connections.discord.id = request.id;
await pnid.save();
} catch (error) {
let message = 'Unknown Mongo error';
if (error instanceof Error) {
message = error.message;
}
throw new ServerError(Status.INVALID_ARGUMENT, message);
}
return {};
}

View File

@ -0,0 +1,80 @@
import { Status, ServerError } from 'nice-grpc';
import { PNID } from '@/models/pnid';
import type { CallContext } from 'nice-grpc';
import type { SetStripeConnectionDataRequest, SetStripeConnectionDataResponse } from '@pretendonetwork/grpc/api/v2/set_stripe_connection_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
type StripeMongoUpdateScheme = {
'access_level'?: number;
'server_access_level'?: string;
'connections.stripe.customer_id'?: string;
'connections.stripe.subscription_id'?: string;
'connections.stripe.price_id'?: string;
'connections.stripe.tier_level'?: number;
'connections.stripe.tier_name'?: string;
'connections.stripe.latest_webhook_timestamp': number;
};
export async function setStripeConnectionData(request: SetStripeConnectionDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<SetStripeConnectionDataResponse> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
const updateData: StripeMongoUpdateScheme = {
'connections.stripe.latest_webhook_timestamp': Number(request.timestamp)
};
if (request.customerId && !pnid.connections.stripe.customer_id) {
updateData['connections.stripe.customer_id'] = request.customerId;
}
if (request.subscriptionId !== undefined) {
updateData['connections.stripe.subscription_id'] = request.subscriptionId;
}
if (request.subscriptionId !== undefined) {
updateData['connections.stripe.subscription_id'] = request.subscriptionId;
}
if (request.priceId !== undefined) {
updateData['connections.stripe.price_id'] = request.priceId;
}
if (request.tierLevel !== undefined) {
updateData['connections.stripe.tier_level'] = request.tierLevel;
}
if (request.tierName !== undefined) {
updateData['connections.stripe.tier_name'] = request.tierName;
}
try {
if (pnid.connections.stripe.latest_webhook_timestamp && pnid.connections.stripe.customer_id) {
// * Stripe customer data has already been initialized, update it
await PNID.updateOne({
'pid': pnid.pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: request.timestamp
}
}, { $set: updateData }).exec();
} else {
// * Initialize a new Stripe user
if (!request.customerId) {
throw new ServerError(Status.INVALID_ARGUMENT, 'No Stripe user data found and no custom ID provided');
}
PNID.updateOne({ pid: pnid.pid }, {
$set: updateData
}, { upsert: true }).exec();
}
} catch (error) {
let message = 'Unknown Mongo error';
if (error instanceof Error) {
message = error.message;
}
throw new ServerError(Status.INVALID_ARGUMENT, message);
}
return {};
}

View File

@ -0,0 +1,47 @@
import { config } from '@/config-manager';
import type { CallContext } from 'nice-grpc';
import type { UpdateUserDataRequest, DeepPartial } from '@pretendonetwork/grpc/api/v2/update_user_data_rpc';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/api/v2/get_user_data_rpc';
import type { AuthenticationCallContextExt } from '@/services/grpc/api/v1/authentication-middleware';
export async function updateUserData(_request: UpdateUserDataRequest, context: CallContext & AuthenticationCallContextExt): Promise<DeepPartial<GetUserDataResponse>> {
// * This is asserted in authentication-middleware, we know this is never null
const pnid = context.pnid!;
// TODO - STUBBED, DO SOMETHING HERE
return {
deleted: pnid.deleted,
creationDate: pnid.creation_date,
updatedDate: pnid.updated,
pid: pnid.pid,
username: pnid.username,
accessLevel: pnid.access_level,
serverAccessLevel: pnid.server_access_level,
mii: {
name: pnid.mii.name,
data: pnid.mii.data,
url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`
},
birthday: pnid.birthdate,
gender: pnid.gender,
country: pnid.country,
timezone: pnid.timezone.name,
language: pnid.language,
emailAddress: pnid.email.address,
connections: {
discord: {
id: pnid.connections.discord.id
},
stripe: {
customerId: pnid.connections.stripe.customer_id,
subscriptionId: pnid.connections.stripe.subscription_id,
priceId: pnid.connections.stripe.price_id,
tierLevel: pnid.connections.stripe.tier_level,
tierName: pnid.connections.stripe.tier_name,
latestWebhookTimestamp: BigInt(pnid.connections.stripe.latest_webhook_timestamp ?? 0)
}
},
marketingFlag: pnid.flags.marketing
};
}

View File

@ -1,18 +1,28 @@
import { createServer } from 'nice-grpc';
import { AccountDefinition } from '@pretendonetwork/grpc/account/account_service';
import { APIDefinition } from '@pretendonetwork/grpc/api/api_service';
import { apiKeyMiddleware as accountApiKeyMiddleware } from '@/services/grpc/account/api-key-middleware';
import { apiKeyMiddleware as apiApiKeyMiddleware } from '@/services/grpc/api/api-key-middleware';
import { authenticationMiddleware as apiAuthenticationMiddleware } from '@/services/grpc/api/authentication-middleware';
import { accountServiceImplementation } from '@/services/grpc/account/implementation';
import { apiServiceImplementation } from '@/services/grpc/api/implementation';
import { AccountDefinition as AccountServiceDefinitionV1 } from '@pretendonetwork/grpc/account/account_service';
import { APIDefinition as ApiServiceDefinitionV1 } from '@pretendonetwork/grpc/api/api_service';
import { apiKeyMiddleware as accountApiKeyMiddlewareV1 } from '@/services/grpc/account/v1/api-key-middleware';
import { apiKeyMiddleware as apiApiKeyMiddlewareV1 } from '@/services/grpc/api/v1/api-key-middleware';
import { authenticationMiddleware as apiAuthenticationMiddlewareV1 } from '@/services/grpc/api/v1/authentication-middleware';
import { accountServiceImplementationV1 } from '@/services/grpc/account/v1/implementation';
import { apiServiceImplementationV1 } from '@/services/grpc/api/v1/implementation';
import { AccountServiceDefinition as AccountServiceDefinitionV2 } from '@pretendonetwork/grpc/account/v2/account_service';
import { ApiServiceDefinition as ApiServiceDefinitionV2 } from '@pretendonetwork/grpc/api/v2/api_service';
import { apiKeyMiddleware as accountApiKeyMiddlewareV2 } from '@/services/grpc/account/v2/api-key-middleware';
import { apiKeyMiddleware as apiApiKeyMiddlewareV2 } from '@/services/grpc/api/v2/api-key-middleware';
import { authenticationMiddleware as apiAuthenticationMiddlewareV2 } from '@/services/grpc/api/v2/authentication-middleware';
import { accountServiceImplementationV2 } from '@/services/grpc/account/v2/implementation';
import { apiServiceImplementationV2 } from '@/services/grpc/api/v2/implementation';
import { config } from '@/config-manager';
export async function startGRPCServer(): Promise<void> {
const server = createServer();
server.with(accountApiKeyMiddleware).add(AccountDefinition, accountServiceImplementation);
server.with(apiApiKeyMiddleware).with(apiAuthenticationMiddleware).add(APIDefinition, apiServiceImplementation);
server.with(accountApiKeyMiddlewareV1).add(AccountServiceDefinitionV1, accountServiceImplementationV1);
server.with(apiApiKeyMiddlewareV1).with(apiAuthenticationMiddlewareV1).add(ApiServiceDefinitionV1, apiServiceImplementationV1);
server.with(accountApiKeyMiddlewareV2).add(AccountServiceDefinitionV2, accountServiceImplementationV2);
server.with(apiApiKeyMiddlewareV2).with(apiAuthenticationMiddlewareV2).add(ApiServiceDefinitionV2, apiServiceImplementationV2);
await server.listen(`0.0.0.0:${config.grpc.port}`);
}