Further login process
* APIUtils addition: query validation * Coach and Server accounts are now properly created if they do not exist * Profiles now cannot be IDs 1 or 2 (reservedIds) * Fixed profile username exists bug * Added relationship manager * Started relationship management * DeviceClass and VRMovementMode enum defaults for reserved profiles * Presence update simplification * Progression fixes * Relationship query and object fixes * Base configuration is now rate limited * Progression route no longer requires authentication, instead is rate limited * Base relationships with reserved profiles (Coach and Server) * DeviceClass required for login * Get presence route * Socket route no longer logs * Socket target base finished
This commit is contained in:
@@ -74,6 +74,15 @@ export const validateRequestBody = <T>(schema: z.ZodSchema<T>) => (rq: express.R
|
|||||||
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
|
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
export const validateQuery = <T>(schema: z.ZodSchema<T>) => (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
|
||||||
|
try {
|
||||||
|
schema.parse(rq.query);
|
||||||
|
nxt();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError)
|
||||||
|
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type genericResponse = {
|
type genericResponse = {
|
||||||
failure: boolean,
|
failure: boolean,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Logging from "@proxnet/undead-logging";
|
|||||||
|
|
||||||
const log = new Logging("Presence");
|
const log = new Logging("Presence");
|
||||||
|
|
||||||
interface PresenceExport {
|
export interface PresenceExport {
|
||||||
roomInstance: RoomInstance | null;
|
roomInstance: RoomInstance | null;
|
||||||
playerId: number;
|
playerId: number;
|
||||||
statusVisibility: PlayerStatusVisibility;
|
statusVisibility: PlayerStatusVisibility;
|
||||||
@@ -14,7 +14,6 @@ interface PresenceExport {
|
|||||||
vrMovementMode?: VRMovementMode;
|
vrMovementMode?: VRMovementMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hot mess
|
|
||||||
class PlayerPresence {
|
class PlayerPresence {
|
||||||
|
|
||||||
intervalId: number;
|
intervalId: number;
|
||||||
@@ -59,27 +58,21 @@ class PlayerPresence {
|
|||||||
if (visibilityResult.success) this.statusVisibility = visibilityResult.data;
|
if (visibilityResult.success) this.statusVisibility = visibilityResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEnums() {
|
async update() {
|
||||||
|
this.updateOffline();
|
||||||
if (!this.offline) await this.updateStatusVisibility();
|
if (!this.offline) await this.updateStatusVisibility();
|
||||||
else this.statusVisibility = PlayerStatusVisibility.Offline;
|
else this.statusVisibility = PlayerStatusVisibility.Offline;
|
||||||
|
|
||||||
// deviceClass
|
this.deviceClass = await this.#profile.getKnownDeviceClass();
|
||||||
const DeviceClassEnum = z.nativeEnum(DeviceClass);
|
|
||||||
type DeviceClassEnum = z.infer<typeof DeviceClassEnum>;
|
|
||||||
|
|
||||||
const deviceClassResult = DeviceClassEnum.safeParse(await this.#profile.getKnownDeviceClass());
|
this.vrMovementMode = await this.#profile.getVRMovementMode();
|
||||||
if (deviceClassResult.success) this.deviceClass = deviceClassResult.data;
|
|
||||||
|
|
||||||
// vrMovementMode
|
|
||||||
const VRMovementModeEnum = z.nativeEnum(VRMovementMode);
|
|
||||||
type VRMovementModeEnum = z.infer<typeof VRMovementModeEnum>;
|
|
||||||
|
|
||||||
const vrMovementMoveResult = VRMovementModeEnum.safeParse(await this.#profile.getVRMovementMode());
|
|
||||||
if (vrMovementMoveResult.success) this.vrMovementMode = vrMovementMoveResult.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export presence object. Please make sure to update values with `Presence.update()` (async) before calling this.
|
||||||
|
*/
|
||||||
async export() {
|
async export() {
|
||||||
await this.updateEnums();
|
await this.update();
|
||||||
const exp: PresenceExport = {
|
const exp: PresenceExport = {
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
roomInstance: this.roomInstance,
|
roomInstance: this.roomInstance,
|
||||||
@@ -108,7 +101,10 @@ class PresenceBase {
|
|||||||
*/
|
*/
|
||||||
async getAllPresences() {
|
async getAllPresences() {
|
||||||
const presSet: Set<PresenceExport> = new Set();
|
const presSet: Set<PresenceExport> = new Set();
|
||||||
for (const pres of presence.values()) presSet.add(await pres.export());
|
for (const pres of presence.values()) {
|
||||||
|
await pres.update();
|
||||||
|
presSet.add(await pres.export());
|
||||||
|
}
|
||||||
return presSet;
|
return presSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +115,7 @@ class PresenceBase {
|
|||||||
async create(player: Profile) {
|
async create(player: Profile) {
|
||||||
if (!presence.values().find(pres => pres.playerId == player.getId())) {
|
if (!presence.values().find(pres => pres.playerId == player.getId())) {
|
||||||
const pres = new PlayerPresence(player);
|
const pres = new PlayerPresence(player);
|
||||||
await pres.updateEnums();
|
await pres.update();
|
||||||
presence.add(pres);
|
presence.add(pres);
|
||||||
}
|
}
|
||||||
log.d(`Presences: ${JSON.stringify(Array.from(await Presence.getAllPresences()))}`);
|
log.d(`Presences: ${JSON.stringify(Array.from(await Presence.getAllPresences()))}`);
|
||||||
@@ -130,7 +126,7 @@ class PresenceBase {
|
|||||||
if (pres) return pres;
|
if (pres) return pres;
|
||||||
else {
|
else {
|
||||||
const pres = new PlayerPresence(player, true);
|
const pres = new PlayerPresence(player, true);
|
||||||
await pres.updateEnums();
|
await pres.update();
|
||||||
presence.add(pres);
|
presence.add(pres);
|
||||||
return pres;
|
return pres;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class ProfileProgressionManager extends ProfileContentManager {
|
|||||||
if (xp >= item.RequiredXp) {
|
if (xp >= item.RequiredXp) {
|
||||||
|
|
||||||
const current = config?.LevelProgressionMaps[config?.LevelProgressionMaps.indexOf(item)];
|
const current = config?.LevelProgressionMaps[config?.LevelProgressionMaps.indexOf(item)];
|
||||||
if (typeof current == 'undefined') return null;
|
if (typeof current == 'undefined') return 1;
|
||||||
else return current.Level;
|
else return current.Level;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,9 @@ export class ProfileProgressionManager extends ProfileContentManager {
|
|||||||
const parsedData = parseInt(data);
|
const parsedData = parseInt(data);
|
||||||
if (isNaN(parsedData)) {
|
if (isNaN(parsedData)) {
|
||||||
log.w(`Parsed xp data for ${this.profileId} is NaN!`);
|
log.w(`Parsed xp data for ${this.profileId} is NaN!`);
|
||||||
return 0; // fallback since progression data is required
|
const one = config?.LevelProgressionMaps[1];
|
||||||
|
if (typeof one == 'undefined' && !one) return 0; // fallback since progression data is required
|
||||||
|
else return one.RequiredXp;
|
||||||
} else return parsedData;
|
} else return parsedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
src/data/profile/relationships.ts
Normal file
64
src/data/profile/relationships.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Profile } from "../profiles.ts";
|
||||||
|
import { ProfileContentManager } from "./base/profilemanagerbase.ts";
|
||||||
|
|
||||||
|
enum RelationshipType {
|
||||||
|
None,
|
||||||
|
FriendRequestSent,
|
||||||
|
FriendRequestReceived,
|
||||||
|
Friend
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReciprocalStatus {
|
||||||
|
None,
|
||||||
|
Local,
|
||||||
|
Remote,
|
||||||
|
Mutual
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Relationship {
|
||||||
|
PlayerID: number, // target player, not this player
|
||||||
|
RelationshipType: RelationshipType,
|
||||||
|
Muted: ReciprocalStatus,
|
||||||
|
Ignored: ReciprocalStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProfileRelationshipManager extends ProfileContentManager {
|
||||||
|
|
||||||
|
#baseRelationships: Relationship[] = [
|
||||||
|
{
|
||||||
|
PlayerID: 1,
|
||||||
|
RelationshipType: RelationshipType.Friend,
|
||||||
|
Muted: ReciprocalStatus.None,
|
||||||
|
Ignored: ReciprocalStatus.None
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PlayerID: 2,
|
||||||
|
RelationshipType: RelationshipType.Friend,
|
||||||
|
Muted: ReciprocalStatus.None,
|
||||||
|
Ignored: ReciprocalStatus.None
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
getRelationships() {
|
||||||
|
return this.#baseRelationships; // temporary
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerIgnored(player: Profile) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerMuted(player: Profile) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerRelationship(player: Profile) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPlayerFriendRequest(player: Profile) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ export class ProfileReputationManager extends ProfileContentManager {
|
|||||||
async getReputation() { // async temporary
|
async getReputation() { // async temporary
|
||||||
return {
|
return {
|
||||||
AccountId: this.profileId,
|
AccountId: this.profileId,
|
||||||
Noterity: 0.0,
|
Noteriety: 0.0,
|
||||||
CheerGeneral: 0,
|
CheerGeneral: 0,
|
||||||
CheerHelpful: 0,
|
CheerHelpful: 0,
|
||||||
|
CheerGreatHost: 0,
|
||||||
CheerSportsman: 0,
|
CheerSportsman: 0,
|
||||||
CheerCreative: 0,
|
CheerCreative: 0,
|
||||||
CheerCredit: 0,
|
CheerCredit: 0,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ProfileSettingsManager } from "./profile/settings.ts";
|
|||||||
import { ProfileProgressionManager } from "./profile/progression.ts";
|
import { ProfileProgressionManager } from "./profile/progression.ts";
|
||||||
import { ProfileReputationManager } from "./profile/reputation.ts";
|
import { ProfileReputationManager } from "./profile/reputation.ts";
|
||||||
import Logging from "@proxnet/undead-logging";
|
import Logging from "@proxnet/undead-logging";
|
||||||
|
import { ProfileRelationshipManager } from "./profile/relationships.ts";
|
||||||
|
|
||||||
const config = Config.getConfig();
|
const config = Config.getConfig();
|
||||||
|
|
||||||
@@ -19,11 +20,17 @@ const log = new Logging("Profiles");
|
|||||||
|
|
||||||
interface ProfileInitOptions {
|
interface ProfileInitOptions {
|
||||||
username: string;
|
username: string;
|
||||||
|
/**
|
||||||
|
* Ignore the random generation of profile IDs; use this ID.
|
||||||
|
*
|
||||||
|
* Make sure the target account does not exist before using this option.
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
}
|
}
|
||||||
interface AccountExport {
|
interface AccountExport {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
profileImage: string;
|
profileImage: string;
|
||||||
isJunior: boolean;
|
isJunior?: boolean;
|
||||||
platforms: number;
|
platforms: number;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -34,6 +41,8 @@ export interface ProfileTokenFormat extends TokenBaseFormat {
|
|||||||
typ: AuthType.Game;
|
typ: AuthType.Game;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reservedIds = [1, 2];
|
||||||
|
|
||||||
class Profile {
|
class Profile {
|
||||||
static async exists(id: number) {
|
static async exists(id: number) {
|
||||||
return (await Redis.Database.exists(
|
return (await Redis.Database.exists(
|
||||||
@@ -44,6 +53,11 @@ class Profile {
|
|||||||
),
|
),
|
||||||
)) >= 1;
|
)) >= 1;
|
||||||
}
|
}
|
||||||
|
static async existsByName(name: string) {
|
||||||
|
return (await Redis.Database.exists(
|
||||||
|
Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name),
|
||||||
|
)) >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
static async getUniqueId() {
|
static async getUniqueId() {
|
||||||
let id = Math.round(Math.random() * Math.pow(2, 31));
|
let id = Math.round(Math.random() * Math.pow(2, 31));
|
||||||
@@ -55,18 +69,15 @@ class Profile {
|
|||||||
Redis.KeyGroups.Profiles.Username,
|
Redis.KeyGroups.Profiles.Username,
|
||||||
),
|
),
|
||||||
)) >= 1
|
)) >= 1
|
||||||
) {
|
|| reservedIds.includes(id)
|
||||||
id = await this.getUniqueId();
|
) id = await this.getUniqueId();
|
||||||
}
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async byName(name: string) {
|
static async byName(name: string) {
|
||||||
const id = await Redis.Database.get(
|
const id = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name));
|
||||||
Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name),
|
|
||||||
);
|
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
else return new Profile(parseInt(id, 10));
|
else return new Profile(parseInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getUniqueUsername() {
|
static async getUniqueUsername() {
|
||||||
@@ -83,12 +94,11 @@ class Profile {
|
|||||||
static async init(options?: ProfileInitOptions) {
|
static async init(options?: ProfileInitOptions) {
|
||||||
const optionsSpecified = typeof options !== "undefined";
|
const optionsSpecified = typeof options !== "undefined";
|
||||||
|
|
||||||
if (options?.username) {
|
if (options?.username && await Profile.existsByName(options.username)) return null;
|
||||||
const existingUser = await Profile.byName(options.username);
|
|
||||||
if (existingUser == null) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = await this.getUniqueId();
|
let newId: number;
|
||||||
|
if (options?.id) newId = options.id;
|
||||||
|
else newId = await this.getUniqueId();
|
||||||
const newUsername = optionsSpecified
|
const newUsername = optionsSpecified
|
||||||
? options.username
|
? options.username
|
||||||
: await this.getUniqueUsername();
|
: await this.getUniqueUsername();
|
||||||
@@ -153,6 +163,7 @@ class Profile {
|
|||||||
Settings = new ProfileSettingsManager();
|
Settings = new ProfileSettingsManager();
|
||||||
Progression = new ProfileProgressionManager();
|
Progression = new ProfileProgressionManager();
|
||||||
Reputation = new ProfileReputationManager();
|
Reputation = new ProfileReputationManager();
|
||||||
|
Relationships = new ProfileRelationshipManager();
|
||||||
|
|
||||||
constructor(id: number) {
|
constructor(id: number) {
|
||||||
this.#id = id;
|
this.#id = id;
|
||||||
@@ -161,6 +172,7 @@ class Profile {
|
|||||||
this.Settings.setProfile(this.#id);
|
this.Settings.setProfile(this.#id);
|
||||||
this.Progression.setProfile(this.#id);
|
this.Progression.setProfile(this.#id);
|
||||||
this.Reputation.setProfile(this.#id);
|
this.Reputation.setProfile(this.#id);
|
||||||
|
this.Relationships.setProfile(this.#id);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInstance(instance: RoomInstance | null) {
|
setInstance(instance: RoomInstance | null) {
|
||||||
@@ -183,11 +195,13 @@ class Profile {
|
|||||||
return await Profile.getExportAccount(this.#id);
|
return await Profile.getExportAccount(this.#id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setKnownDeviceClass(deviceClass: string | number) {
|
async setKnownDeviceClass(deviceClass: DeviceClass) {
|
||||||
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass), deviceClass);
|
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass), deviceClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getKnownDeviceClass() {
|
async getKnownDeviceClass() {
|
||||||
|
if (reservedIds.includes(this.#id)) return DeviceClass.Unknown;
|
||||||
|
|
||||||
const data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass));
|
const data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass));
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
log.w(`No known device class for ${this.#id}`);
|
log.w(`No known device class for ${this.#id}`);
|
||||||
@@ -212,14 +226,16 @@ class Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getVRMovementMode() {
|
async getVRMovementMode() {
|
||||||
|
if (reservedIds.includes(this.#id)) return VRMovementMode.Teleport;
|
||||||
|
|
||||||
const data = await this.Settings.getSetting(SettingKey.VRMovementMode);
|
const data = await this.Settings.getSetting(SettingKey.VRMovementMode);
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
log.w(`No known device class for ${this.#id}`);
|
log.w(`No known VR movement mode for ${this.#id} (harmless if OOBE not ran)`);
|
||||||
return VRMovementMode.Teleport;
|
return VRMovementMode.Teleport;
|
||||||
}
|
}
|
||||||
const parsedData = parseInt(data);
|
const parsedData = parseInt(data);
|
||||||
if (isNaN(parsedData)) {
|
if (isNaN(parsedData)) {
|
||||||
log.w(`Malformed device class for ${this.#id}`);
|
log.w(`Malformed VR movement mode for ${this.#id}`);
|
||||||
return VRMovementMode.Teleport;
|
return VRMovementMode.Teleport;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +274,7 @@ class Profile {
|
|||||||
|
|
||||||
const profiles: Map<number, Profile> = new Map()
|
const profiles: Map<number, Profile> = new Map()
|
||||||
|
|
||||||
|
// Control what is available to references
|
||||||
class UnifiedProfileBase {
|
class UnifiedProfileBase {
|
||||||
|
|
||||||
get(id: number) {
|
get(id: number) {
|
||||||
@@ -278,6 +295,10 @@ class UnifiedProfileBase {
|
|||||||
return await Profile.exists(id);
|
return await Profile.exists(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async existsByName(name: string) {
|
||||||
|
return await Profile.existsByName(name);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UnifiedProfile = new UnifiedProfileBase();
|
const UnifiedProfile = new UnifiedProfileBase();
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ try {
|
|||||||
PLACE TEST HERE
|
PLACE TEST HERE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (!(await UnifiedProfile.exists(1))) UnifiedProfile.create({ username: "Coach" }); // create Coach if they do not exist
|
if (!(await UnifiedProfile.existsByName("Coach"))) UnifiedProfile.create({ username: "Coach", id: 1 }); // create Coach id 1 if they do not exist
|
||||||
if (!(await UnifiedProfile.exists(2))) UnifiedProfile.create({ username: "Server" }); // create Server if they do not exist
|
if (!(await UnifiedProfile.existsByName("Server"))) UnifiedProfile.create({ username: "Server", id: 2 }); // create Server id 2 if they do not exist
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ route.router.post("/create",
|
|||||||
|
|
||||||
async (_rq, rs) => {
|
async (_rq, rs) => {
|
||||||
const newAcc = await Profile.init();
|
const newAcc = await Profile.init();
|
||||||
|
if (newAcc == null) {
|
||||||
|
rs.json({
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
rs.locals.user.addAssociatedProfile(newAcc.getId());
|
rs.locals.user.addAssociatedProfile(newAcc.getId());
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { GameConfigs } from "../../data/config.ts";
|
|||||||
|
|
||||||
export const route = APIUtils.createRouter("/config");
|
export const route = APIUtils.createRouter("/config");
|
||||||
|
|
||||||
route.router.get("/v2", (_rq, rs) => {
|
const rateLimit = new APIUtils.RateLimiter(60, 2);
|
||||||
|
|
||||||
|
route.router.get("/v2", rateLimit.middle(), (_rq, rs) => {
|
||||||
const config = GameConfigs.getConfig();
|
const config = GameConfigs.getConfig();
|
||||||
if (config == null) rs.sendStatus(500);
|
if (config == null) rs.sendStatus(500);
|
||||||
else rs.json(config);
|
else rs.json(config);
|
||||||
@@ -11,6 +13,7 @@ route.router.get("/v2", (_rq, rs) => {
|
|||||||
|
|
||||||
route.router.get('/v1/amplitude',
|
route.router.get('/v1/amplitude',
|
||||||
APIUtils.setCacheAllowed,
|
APIUtils.setCacheAllowed,
|
||||||
|
rateLimit.middle(),
|
||||||
(_rq, rs) => {
|
(_rq, rs) => {
|
||||||
rs.json({AmplitudeKey: ""});
|
rs.json({AmplitudeKey: ""});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ route.router.get('/v1/:id',
|
|||||||
APIUtils.Authentication,
|
APIUtils.Authentication,
|
||||||
APIUtils.AuthenticationType(AuthType.Game),
|
APIUtils.AuthenticationType(AuthType.Game),
|
||||||
|
|
||||||
(rq: express.Request<{ id: string }>, rs) => {
|
async (rq: express.Request<{ id: string }>, rs) => {
|
||||||
const unparsedPlayerId = rq.params.id;
|
const unparsedPlayerId = rq.params.id;
|
||||||
const parsedPlayerId = parseInt(unparsedPlayerId);
|
const parsedPlayerId = parseInt(unparsedPlayerId);
|
||||||
if (isNaN(parsedPlayerId)) {
|
if (isNaN(parsedPlayerId)) {
|
||||||
@@ -18,7 +18,7 @@ route.router.get('/v1/:id',
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rs.json(UnifiedProfile.get(parsedPlayerId).Reputation.getReputation());
|
rs.json(await UnifiedProfile.get(parsedPlayerId).Reputation.getReputation());
|
||||||
}
|
}
|
||||||
|
|
||||||
);
|
);
|
||||||
@@ -5,11 +5,13 @@ import UnifiedProfile from "../../data/profiles.ts";
|
|||||||
|
|
||||||
const log = new Logging("ProgressionRoute");
|
const log = new Logging("ProgressionRoute");
|
||||||
|
|
||||||
|
const rateLimit = new APIUtils.RateLimiter(60, 2);
|
||||||
|
|
||||||
export const route = APIUtils.createRouter("/players");
|
export const route = APIUtils.createRouter("/players");
|
||||||
|
|
||||||
route.router.get('/v1/progression/:id',
|
route.router.get('/v1/progression/:id',
|
||||||
|
|
||||||
APIUtils.Authentication,
|
rateLimit.middle(),
|
||||||
|
|
||||||
async (rq: express.Request<{ id: string }>, rs) => {
|
async (rq: express.Request<{ id: string }>, rs) => {
|
||||||
const unparsedPlayerId = rq.params.id;
|
const unparsedPlayerId = rq.params.id;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ route.router.get('/v2/get',
|
|||||||
APIUtils.AuthenticationType(AuthType.Game),
|
APIUtils.AuthenticationType(AuthType.Game),
|
||||||
|
|
||||||
(_rq, rs) => {
|
(_rq, rs) => {
|
||||||
rs.json([]); // temporary
|
rs.json(rs.locals.profile.Relationships.getRelationships());
|
||||||
}
|
}
|
||||||
|
|
||||||
);
|
);
|
||||||
@@ -98,6 +98,7 @@ route.router.post("/token",
|
|||||||
rq.body.platform === "0",
|
rq.body.platform === "0",
|
||||||
rq.body.ver === '20191120',
|
rq.body.ver === '20191120',
|
||||||
rq.body.device_class.length === 1,
|
rq.body.device_class.length === 1,
|
||||||
|
!isNaN(Number(rq.body.device_class)),
|
||||||
!(rq.body.device_id.length > 96),
|
!(rq.body.device_id.length > 96),
|
||||||
!(rq.body.client_secret.length > 96),
|
!(rq.body.client_secret.length > 96),
|
||||||
!(rq.body.platform_id.length > 32),
|
!(rq.body.platform_id.length > 32),
|
||||||
@@ -110,7 +111,6 @@ route.router.post("/token",
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await rs.locals.user.getAssociatedProfiles();
|
|
||||||
let targetAccount: number;
|
let targetAccount: number;
|
||||||
|
|
||||||
if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id);
|
if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id);
|
||||||
@@ -137,6 +137,8 @@ route.router.post("/token",
|
|||||||
requestFailed();
|
requestFailed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accounts = await rs.locals.user.getAssociatedProfiles();
|
||||||
if (!accounts.has(targetAccount)) {
|
if (!accounts.has(targetAccount)) {
|
||||||
requestFailed("access_denied");
|
requestFailed("access_denied");
|
||||||
return;
|
return;
|
||||||
@@ -160,6 +162,6 @@ route.router.post("/token",
|
|||||||
refresh_token: token,
|
refresh_token: token,
|
||||||
});
|
});
|
||||||
|
|
||||||
await profile.setKnownDeviceClass(rq.body.device_class);
|
await profile.setKnownDeviceClass(Number(rq.body.device_class));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { z } from "zod";
|
|||||||
import { APIUtils, NoBody } from "../../apiutils.ts";
|
import { APIUtils, NoBody } from "../../apiutils.ts";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import Matchmaking from "../../data/live/base.ts";
|
import Matchmaking from "../../data/live/base.ts";
|
||||||
import Presence from "../../data/live/presence.ts";
|
import Presence, { PresenceExport } from "../../data/live/presence.ts";
|
||||||
import { AuthType } from "../../data/users.ts";
|
import { AuthType } from "../../data/users.ts";
|
||||||
import Logging from "@proxnet/undead-logging";
|
import Logging from "@proxnet/undead-logging";
|
||||||
|
import UnifiedProfile from "../../data/profiles.ts";
|
||||||
|
|
||||||
const log = new Logging("MatchPlayerRoute");
|
const log = new Logging("MatchPlayerRoute");
|
||||||
|
|
||||||
@@ -18,6 +19,31 @@ const LoginSchema = z.object({
|
|||||||
LoginLock: z.string().uuid("LoginLock must be a UUIDv4")
|
LoginLock: z.string().uuid("LoginLock must be a UUIDv4")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
route.router.get('/',
|
||||||
|
|
||||||
|
APIUtils.Authentication,
|
||||||
|
APIUtils.AuthenticationType(AuthType.Game),
|
||||||
|
APIUtils.validateQuery(z.object({ id: z.union([z.string(), z.array(z.string())]) })),
|
||||||
|
|
||||||
|
async (rq: express.Request<NoBody, PresenceExport[], NoBody, { id: string[] | string }>, rs) => {
|
||||||
|
let ids: number[] = [];
|
||||||
|
if (typeof rq.query.id == 'object') ids = rq.query.id.map(val => parseInt(val));
|
||||||
|
else ids.push(parseInt(rq.query.id));
|
||||||
|
ids = ids.filter(val => !isNaN(val));
|
||||||
|
|
||||||
|
const presExport: PresenceExport[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const pres = await Presence.get(UnifiedProfile.get(id));
|
||||||
|
await pres.update();
|
||||||
|
presExport.push(await pres.export());
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.json(presExport);
|
||||||
|
log.d(JSON.stringify(presExport));
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
route.router.post('/login',
|
route.router.post('/login',
|
||||||
|
|
||||||
APIUtils.Authentication,
|
APIUtils.Authentication,
|
||||||
@@ -40,8 +66,9 @@ route.router.post('/logout',
|
|||||||
express.urlencoded({extended: true}),
|
express.urlencoded({extended: true}),
|
||||||
APIUtils.validateRequestBody(LoginSchema),
|
APIUtils.validateRequestBody(LoginSchema),
|
||||||
|
|
||||||
(rq, rs) => {
|
(_rq, rs) => {
|
||||||
Matchmaking.deleteLoginLock(rs.locals.profile);
|
Matchmaking.deleteLoginLock(rs.locals.profile);
|
||||||
|
rs.sendStatus(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -56,6 +83,7 @@ route.router.post('/heartbeat',
|
|||||||
|
|
||||||
async (_rq, rs) => {
|
async (_rq, rs) => {
|
||||||
const pres = await Presence.get(rs.locals.profile);
|
const pres = await Presence.get(rs.locals.profile);
|
||||||
|
await pres.update();
|
||||||
log.d(`pres heartbeat for ${rs.locals.profile.getId()}: ${JSON.stringify(await pres.export())}`);
|
log.d(`pres heartbeat for ${rs.locals.profile.getId()}: ${JSON.stringify(await pres.export())}`);
|
||||||
rs.json(await pres.export());
|
rs.json(await pres.export());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { APIUtils } from "../apiutils.ts";
|
|||||||
import { Config } from "../config.ts";
|
import { Config } from "../config.ts";
|
||||||
import { AuthType } from "../data/users.ts";
|
import { AuthType } from "../data/users.ts";
|
||||||
import { SocketHandoff } from "./handoff.ts";
|
import { SocketHandoff } from "./handoff.ts";
|
||||||
import express from "express";
|
|
||||||
|
|
||||||
const config = Config.getConfig();
|
const config = Config.getConfig();
|
||||||
|
|
||||||
@@ -12,8 +11,6 @@ route.router.post('/hub/v1/negotiate',
|
|||||||
|
|
||||||
APIUtils.Authentication,
|
APIUtils.Authentication,
|
||||||
APIUtils.AuthenticationType(AuthType.Game),
|
APIUtils.AuthenticationType(AuthType.Game),
|
||||||
express.urlencoded({ extended: true }),
|
|
||||||
APIUtils.logBody,
|
|
||||||
|
|
||||||
(_rq, rs) => {
|
(_rq, rs) => {
|
||||||
const handoff = new SocketHandoff();
|
const handoff = new SocketHandoff();
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { Profile } from "../data/profiles.ts";
|
import { Profile } from "../data/profiles.ts";
|
||||||
import Logging from "@proxnet/undead-logging";
|
import Logging from "@proxnet/undead-logging";
|
||||||
import { Message, MessageKind, SignalMessageType, SignalRMessage, SignalRMessageSchema, TargetResult, TargetResultFailure, TargetResultSuccess, TargetResultType } from "./types.ts";
|
import {
|
||||||
|
CompletionMessage,
|
||||||
|
Message,
|
||||||
|
MessageKind,
|
||||||
|
SignalMessageType,
|
||||||
|
SignalRMessage,
|
||||||
|
SignalRMessageSchema,
|
||||||
|
TargetResult,
|
||||||
|
TargetResultFailure,
|
||||||
|
TargetResultNotATarget,
|
||||||
|
TargetResultSuccess,
|
||||||
|
TargetResultType
|
||||||
|
} from "./types.ts";
|
||||||
import { SocketTarget } from "./targets/targetbase.ts";
|
import { SocketTarget } from "./targets/targetbase.ts";
|
||||||
import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts";
|
import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts";
|
||||||
|
|
||||||
@@ -26,22 +38,55 @@ export class SignalRSocketHandler {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #dispatchTarget<T = unknown>(target: string, args: object[]): Promise<TargetResult> {
|
async #dispatchTarget<T = unknown>(target: string, args: unknown): Promise<TargetResult> {
|
||||||
const targetExec = this.#Targets.get(target);
|
const targetExec = this.#Targets.get(target);
|
||||||
if (!targetExec) return { type: TargetResultType.Failure } as TargetResultFailure;
|
if (!targetExec) return { type: TargetResultType.NotATarget } as TargetResultNotATarget;
|
||||||
else return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess<T>;
|
else {
|
||||||
|
try {
|
||||||
|
return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess<T>;
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.w(`Target '${target}' function error: ${err}`);
|
||||||
|
if (err instanceof Error) return { type: TargetResultType.Failure, err: err } as TargetResultFailure;
|
||||||
|
else return { type: TargetResultType.Failure, err: `${err}` } as TargetResultFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#onMessage(message: Message) {
|
async #onMessage(message: Message) {
|
||||||
if (message.kind == MessageKind.Protocol) {
|
if (message.kind == MessageKind.Protocol) {
|
||||||
this.sendRaw({});
|
this.sendRaw({});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`);
|
this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`);
|
||||||
|
if (message.data.type == SignalMessageType.Invocation && message.data.invocationId) { // don't send completion messages for nonblocking invocations
|
||||||
|
const res = await this.#dispatchTarget(message.data.target, message.data.arguments[0]); // rec room only uses the first index
|
||||||
|
if (res.type == TargetResultType.Success) {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
result: JSON.stringify(res.data)
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
} else if (res.type == TargetResultType.Failure) {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
error: res.err instanceof Error ? res.err.message : res.err
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
} else {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
error: "Target not found"
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #init() {
|
#init() {
|
||||||
this.#log.source += this.#profile.getId().toString();
|
this.#log.source += this.#profile.getId().toString();
|
||||||
|
|
||||||
this.#log.i(`Created hub socket`);
|
this.#log.i(`Created hub socket`);
|
||||||
@@ -82,6 +127,9 @@ export class SignalRSocketHandler {
|
|||||||
|
|
||||||
sendRaw(data: object) {
|
sendRaw(data: object) {
|
||||||
this.#socket.send(`${JSON.stringify(data)}\u001e`);
|
this.#socket.send(`${JSON.stringify(data)}\u001e`);
|
||||||
|
// todo sometime: make this less confusing
|
||||||
|
const type = `Type: ${JSON.stringify(data) == '{}' ? 'Protocol Message' : `${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`}`;
|
||||||
|
this.#log.d(`SERVER MESSAGE\n ${type}\n ${JSON.stringify(data as SignalRMessage)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import { SocketTarget } from "./targetbase.ts";
|
import { SocketTarget } from "./targetbase.ts";
|
||||||
|
|
||||||
|
const ArgumentSchema = z.object({
|
||||||
|
PlayerIds: z.array(z.number())
|
||||||
|
});
|
||||||
|
|
||||||
export class PlayerSocketSubscriptionTarget extends SocketTarget {
|
export class PlayerSocketSubscriptionTarget extends SocketTarget {
|
||||||
|
|
||||||
subscriptions: number[] = [];
|
subscriptions: number[] = [];
|
||||||
@@ -9,8 +14,12 @@ export class PlayerSocketSubscriptionTarget extends SocketTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deno-lint-ignore require-await
|
// deno-lint-ignore require-await
|
||||||
override async exec(_args: (object | string | number | boolean)[]) {
|
override async exec(args: unknown) {
|
||||||
return;
|
const parsed = ArgumentSchema.safeParse(args);
|
||||||
|
if (parsed.success) {
|
||||||
|
this.setSubscriptions(parsed.data.PlayerIds);
|
||||||
|
return;
|
||||||
|
} else throw new Error("Invalid arguments");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ export class SocketTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deno-lint-ignore require-await
|
// deno-lint-ignore require-await
|
||||||
async exec(_args: (object | string | number | boolean)[]) {
|
async exec(_args: unknown) {
|
||||||
throw new Error("Execution for this target is not set.");
|
throw new Error("Execution for this target is not set.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ export enum MessageKind {
|
|||||||
Protocol,
|
Protocol,
|
||||||
Data
|
Data
|
||||||
}
|
}
|
||||||
interface MessageBase {
|
export interface MessageBase {
|
||||||
kind: MessageKind
|
kind: MessageKind
|
||||||
}
|
}
|
||||||
interface DataMessage extends MessageBase {
|
export interface DataMessage extends MessageBase {
|
||||||
kind: MessageKind.Data,
|
kind: MessageKind.Data,
|
||||||
data: SignalRMessage
|
data: SignalRMessage
|
||||||
}
|
}
|
||||||
interface ProtocolMessage extends MessageBase {
|
export interface ProtocolMessage extends MessageBase {
|
||||||
kind: MessageKind.Protocol
|
kind: MessageKind.Protocol
|
||||||
}
|
}
|
||||||
export type Message = ProtocolMessage | DataMessage;
|
export type Message = ProtocolMessage | DataMessage;
|
||||||
@@ -34,68 +34,68 @@ export enum SignalMessageType {
|
|||||||
Close
|
Close
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseMessage {
|
export interface BaseMessage {
|
||||||
type: SignalMessageType;
|
type: SignalMessageType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InvocationMessage extends BaseMessage {
|
export interface InvocationMessage extends BaseMessage {
|
||||||
type: SignalMessageType.Invocation;
|
type: SignalMessageType.Invocation;
|
||||||
target: string;
|
target: string;
|
||||||
arguments: unknown[];
|
arguments: unknown[];
|
||||||
invocationId?: string;
|
invocationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreamItemMessage extends BaseMessage {
|
export interface StreamItemMessage extends BaseMessage {
|
||||||
type: SignalMessageType.StreamItem;
|
type: SignalMessageType.StreamItem;
|
||||||
invocationId: string;
|
invocationId: string;
|
||||||
item: unknown;
|
item: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompletionMessage extends BaseMessage {
|
export interface CompletionMessage extends BaseMessage {
|
||||||
type: SignalMessageType.Completion;
|
type: SignalMessageType.Completion;
|
||||||
invocationId: string;
|
invocationId: string;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PingMessage extends BaseMessage {
|
export interface PingMessage extends BaseMessage {
|
||||||
type: SignalMessageType.Ping;
|
type: SignalMessageType.Ping;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloseMessage extends BaseMessage {
|
export interface CloseMessage extends BaseMessage {
|
||||||
type: SignalMessageType.Close;
|
type: SignalMessageType.Close;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BaseMessageSchema = z.object({
|
export const BaseMessageSchema = z.object({
|
||||||
type: z.nativeEnum(SignalMessageType),
|
type: z.nativeEnum(SignalMessageType),
|
||||||
});
|
});
|
||||||
|
|
||||||
const InvocationMessageSchema = BaseMessageSchema.extend({
|
export const InvocationMessageSchema = BaseMessageSchema.extend({
|
||||||
type: z.literal(SignalMessageType.Invocation),
|
type: z.literal(SignalMessageType.Invocation),
|
||||||
target: z.string(),
|
target: z.string(),
|
||||||
arguments: z.array(z.unknown()),
|
arguments: z.array(z.unknown()),
|
||||||
invocationId: z.string().optional(),
|
invocationId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const StreamItemMessageSchema = BaseMessageSchema.extend({
|
export const StreamItemMessageSchema = BaseMessageSchema.extend({
|
||||||
type: z.literal(SignalMessageType.StreamItem),
|
type: z.literal(SignalMessageType.StreamItem),
|
||||||
invocationId: z.string(),
|
invocationId: z.string(),
|
||||||
item: z.unknown(),
|
item: z.unknown(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CompletionMessageSchema = BaseMessageSchema.extend({
|
export const CompletionMessageSchema = BaseMessageSchema.extend({
|
||||||
type: z.literal(SignalMessageType.Completion),
|
type: z.literal(SignalMessageType.Completion),
|
||||||
invocationId: z.string(),
|
invocationId: z.string(),
|
||||||
result: z.unknown().optional(),
|
result: z.unknown().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PingMessageSchema = BaseMessageSchema.extend({
|
export const PingMessageSchema = BaseMessageSchema.extend({
|
||||||
type: z.literal(SignalMessageType.Ping),
|
type: z.literal(SignalMessageType.Ping),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CloseMessageSchema = BaseMessageSchema.extend({
|
export const CloseMessageSchema = BaseMessageSchema.extend({
|
||||||
type: z.literal(SignalMessageType.Close),
|
type: z.literal(SignalMessageType.Close),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -113,7 +113,7 @@ export enum TargetResultType {
|
|||||||
Failure,
|
Failure,
|
||||||
NotATarget
|
NotATarget
|
||||||
}
|
}
|
||||||
interface TargetResultBase {
|
export interface TargetResultBase {
|
||||||
type: TargetResultType
|
type: TargetResultType
|
||||||
}
|
}
|
||||||
export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
|
export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
|
||||||
@@ -122,6 +122,7 @@ export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
|
|||||||
}
|
}
|
||||||
export interface TargetResultFailure extends TargetResultBase {
|
export interface TargetResultFailure extends TargetResultBase {
|
||||||
type: TargetResultType.Failure
|
type: TargetResultType.Failure
|
||||||
|
err: string | Error
|
||||||
}
|
}
|
||||||
export interface TargetResultNotATarget extends TargetResultBase {
|
export interface TargetResultNotATarget extends TargetResultBase {
|
||||||
type: TargetResultType.NotATarget
|
type: TargetResultType.NotATarget
|
||||||
|
|||||||
Reference in New Issue
Block a user