diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 5295740c..2d19298c 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -127,7 +127,7 @@ async function handleBootstrapCommand( } let adminRole = lib.ensureDefaultAdminRole(userManager.roles); - admin.roles.add(adminRole); + admin.roleIds.add(adminRole.id); admin.isAdmin = true; await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json")); diff --git a/packages/controller/index.ts b/packages/controller/index.ts index cd3388a7..89287589 100644 --- a/packages/controller/index.ts +++ b/packages/controller/index.ts @@ -1,8 +1,10 @@ import { bootstrap } from "./controller"; export { default as Controller } from "./src/Controller"; +export { default as ControllerUser } from "./src/ControllerUser"; export { default as ControlConnection } from "./src/ControlConnection"; export { default as InstanceInfo } from "./src/InstanceInfo"; export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin"; +export { default as UserManager } from "./src/UserManager"; if (module === require.main) { bootstrap(); diff --git a/packages/controller/package.json b/packages/controller/package.json index cb9cad53..042a53ad 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@clusterio/lib": "^2.0.0-alpha.13", + "@sinclair/typebox": "^0.30.4", "busboy": "^1.6.0", "compression": "^1.7.4", "express": "^4.18.2", diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index d4c8d779..4e0ba940 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib"; const { logFilter, logger } = lib; import BaseConnection from "./BaseConnection"; +import ControllerUser from "./ControllerUser"; import * as routes from "./routes"; import Controller from "./Controller"; @@ -43,7 +44,7 @@ export default class ControlConnection extends BaseConnection { registerData: { agent:string, version:string }, connector: WsServerConnector, controller: Controller, - public user: lib.User, // The user making this connection. + public user: ControllerUser, // The user making this connection. public id: number ) { super(connector, controller); @@ -635,28 +636,11 @@ export default class ControlConnection extends BaseConnection { } async handlePermissionListRequest() { - let list = []; - for (let permission of lib.permissions.values()) { - list.push(new lib.RawPermission( - permission.name, - permission.title, - permission.description, - )); - } - return list; + return [...lib.permissions.values()]; } async handleRoleListRequest() { - let list = []; - for (let role of this._controller.userManager.roles.values()) { - list.push(new lib.RawRole( - role.id, - role.name, - role.description, - [...role.permissions], - )); - } - return list; + return [...this._controller.userManager.roles.values()]; } async handleRoleCreateRequest(request: lib.RoleCreateRequest) { @@ -664,7 +648,7 @@ export default class ControlConnection extends BaseConnection { // Start at 5 to leave space for future default roles let id = Math.max(5, lastId+1); - this._controller.userManager.roles.set(id, new lib.Role({ id, ...request })); + this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request })); this._controller.userManager.dirty = true; return id; } @@ -694,57 +678,31 @@ export default class ControlConnection extends BaseConnection { async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) { let id = request.id; - let role = this._controller.userManager.roles.get(id); - if (!role) { + + if (!this._controller.userManager.roles.delete(id)) { throw new lib.RequestError(`Role with ID ${id} does not exist`); } - - this._controller.userManager.roles.delete(id); this._controller.userManager.dirty = true; + for (let user of this._controller.userManager.users.values()) { - user.roles.delete(role); - this._controller.userPermissionsUpdated(user); + if (user.roleIds.delete(id)) { + this._controller.userPermissionsUpdated(user); + } } } - async handleUserGetRequest(request: lib.UserGetRequest) { + async handleUserGetRequest(request: lib.UserGetRequest): Promise { let name = request.name; let user = this._controller.userManager.users.get(name); if (!user) { throw new lib.RequestError(`User ${name} does not exist`); } - return new lib.RawUser( - user.name, - [...user.roles].map(role => role.id), - [...user.instances], - user.isAdmin, - user.isBanned, - user.isWhitelisted, - user.banReason, - undefined, - user.playerStats, - user.instanceStats, - ); + return user; } - async handleUserListRequest() { - let list = []; - for (let user of this._controller.userManager.users.values()) { - list.push(new lib.RawUser( - user.name, - [...user.roles].map(role => role.id), - [...user.instances], - user.isAdmin, - user.isBanned, - user.isWhitelisted, - undefined, - undefined, - user.playerStats, - undefined, - )); - } - return list; + async handleUserListRequest(): Promise { + return [...this._controller.userManager.users.values()]; } async handleUserCreateRequest(request: lib.UserCreateRequest) { @@ -776,17 +734,13 @@ export default class ControlConnection extends BaseConnection { throw new lib.RequestError(`User '${request.name}' does not exist`); } - let resolvedRoles: Set = new Set(); for (let roleId of request.roles) { - let role = this._controller.userManager.roles.get(roleId); - if (!role) { + if (!this._controller.userManager.roles.has(roleId)) { throw new lib.RequestError(`Role with ID ${roleId} does not exist`); } - - resolvedRoles.add(role); } - user.roles = resolvedRoles; + user.roleIds = new Set(request.roles); this._controller.userPermissionsUpdated(user); this._controller.userUpdated(user); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 112be76c..ce132d28 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -19,6 +19,7 @@ import HttpCloser from "./HttpCloser"; import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; import * as metrics from "./metrics"; import * as routes from "./routes"; +import ControllerUser from "./ControllerUser"; import UserManager from "./UserManager"; import WsServer from "./WsServer"; import HostConnection, { type HostInfo } from "./HostConnection"; @@ -879,22 +880,9 @@ export default class Controller { lib.invokeHook(this.plugins, "onModUpdated", mod); } - userUpdated(user: lib.User) { + userUpdated(user: ControllerUser) { this.userManager.dirty = true; - this.subscriptions.broadcast(new lib.UserUpdateEvent( - new lib.RawUser( - user.name, - [...user.roles].map(role => role.id), - [...user.instances], - user.isAdmin, - user.isBanned, - user.isWhitelisted, - user.banReason, - user.isDeleted, - user.playerStats, - user.instanceStats, - ) - )); + this.subscriptions.broadcast(new lib.UserUpdateEvent(user)); } /** @@ -902,7 +890,7 @@ export default class Controller { * permissions for this user may have changed. * @param user - User permisions updated for. */ - userPermissionsUpdated(user: lib.User) { + userPermissionsUpdated(user: ControllerUser) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user === user) { controlConnection.send( diff --git a/packages/controller/src/ControllerUser.ts b/packages/controller/src/ControllerUser.ts new file mode 100644 index 00000000..7c79dfb7 --- /dev/null +++ b/packages/controller/src/ControllerUser.ts @@ -0,0 +1,92 @@ +import { Static, Type } from "@sinclair/typebox"; +import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib"; +import type UserManager from "./UserManager"; + +export default class ControllerUser extends User implements IControllerUser { + constructor( + public userManager: UserManager, + /** Unix time in seconds the user token must be issued after to be valid. */ + public tokenValidAfter = 0, + ...args: ConstructorParameters + ) { + super(...args); + } + + static jsonSchema = Type.Object({ + ...User.jsonSchema.properties, + token_valid_after: Type.Optional(Type.Number()), + }); + + static fromJSON(json: Static, userManager: UserManager) { + const roleIds = new Set(json.roles ?? []); + const instanceStats = new Map( + (json.instance_stats ?? []).map( + ([id, stats]) => [id, PlayerStats.fromJSON(stats)] + ) + ); + const playerStats = User._calculatePlayerStats(instanceStats); + return new this( + userManager, + json.token_valid_after, + json.name, + roleIds, + new Set(json.instances), + json.is_admin, + json.is_banned, + json.is_whitelisted, + json.ban_reason, + json.is_deleted, + playerStats, + instanceStats, + ); + } + + toJSON(controller?: boolean): Static { + if (!controller) { + return super.toJSON(); + } + + const json: Static = super.toJSON(); + if (this.tokenValidAfter) { + json.token_valid_after = this.tokenValidAfter; + } + + return json; + } + + get roles(): ReadonlySet> { + return new Set([...this.roleIds].map( + id => this.userManager.roles.get(id) + ).filter( + (r): r is Role => Boolean(r) + )); + } + + /** + * Invalidate current tokens for the user + * + * Sets the tokenValidAfter property to the current time, which causes + * all currently issued tokens for the user to become invalid. + */ + invalidateToken() { + this.tokenValidAfter = Math.floor(Date.now() / 1000); + } + + checkPermission(permission: string) { + if (!permissions.has(permission)) { + throw new Error(`permission ${permission} does not exist`); + } + + for (let roleId of this.roleIds) { + let role = this.userManager.roles.get(roleId); + if (!role) { + continue; + } + if (role.permissions.has("core.admin") || role.permissions.has(permission)) { + return; + } + } + + throw new PermissionError("Permission denied"); + } +} diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 8d893cd6..9dec091e 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -2,7 +2,7 @@ import type Controller from "./Controller"; import type WsServerConnector from "./WsServerConnector"; import * as lib from "@clusterio/lib"; -const { logger, PlayerStats } = lib; +const { logger } = lib; import BaseConnection from "./BaseConnection"; import InstanceInfo, { InstanceStatus } from "./InstanceInfo"; @@ -272,7 +272,6 @@ export default class HostConnection extends BaseConnection { } async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) { - let instanceId = src.id; let user = this._controller.userManager.users.get(event.name); if (!user) { @@ -280,9 +279,9 @@ export default class HostConnection extends BaseConnection { } if (event.type === "join") { - user.notifyJoin(instanceId); + this._controller.userManager.notifyJoin(user, instanceId); } else if (event.type === "leave") { - user.notifyLeave(instanceId); + this._controller.userManager.notifyLeave(user, instanceId); } if (event.stats) { diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts index eadaef9a..bb3cde0a 100644 --- a/packages/controller/src/UserManager.ts +++ b/packages/controller/src/UserManager.ts @@ -3,15 +3,22 @@ import jwt from "jsonwebtoken"; import * as lib from "@clusterio/lib"; +import ControllerUser from "./ControllerUser"; + /** * Manages users and roles * @alias module:controller/src/UserManager */ export default class UserManager { roles: Map = new Map(); - users: Map = new Map(); + users: Map = new Map(); dirty = false; + /** + * Set of users currently online in the cluster. + */ + onlineUsers = new Set(); + constructor( private _config: lib.ControllerConfig ) { @@ -21,12 +28,12 @@ export default class UserManager { try { let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" })); for (let serializedRole of content.roles) { - let role = new lib.Role(serializedRole); + let role = lib.Role.fromJSON(serializedRole); this.roles.set(role.id, role); } for (let serializedUser of content.users) { - let user = new lib.User(serializedUser, this.roles); + let user = ControllerUser.fromJSON(serializedUser, this); this.users.set(user.name, user); } @@ -44,12 +51,12 @@ export default class UserManager { async save(filePath:string): Promise { let serializedRoles = []; for (let role of this.roles.values()) { - serializedRoles.push(role.serialize()); + serializedRoles.push(role.toJSON()); } let serializedUsers = []; for (let user of this.users.values()) { - serializedUsers.push(user.serialize()); + serializedUsers.push(user.toJSON(true)); } let serialized = { @@ -65,18 +72,18 @@ export default class UserManager { * @param name - Name of the user to create. * @returns The created user. */ - createUser(name:string): lib.User { + createUser(name:string): ControllerUser { if (this.users.has(name)) { throw new Error(`User '${name}' already exists`); } - let roles = []; + let roles = new Set(); let defaultRoleId = this._config.get("controller.default_role_id"); - if (defaultRoleId !== null) { - roles.push(defaultRoleId); + if (defaultRoleId !== null && this.roles.has(defaultRoleId)) { + roles.add(defaultRoleId); } - let user = new lib.User({ name, roles }, this.roles); + let user = new ControllerUser(this, 0, name, roles); this.users.set(name, user); this.dirty = true; return user; @@ -94,4 +101,16 @@ export default class UserManager { Buffer.from(this._config.get("controller.auth_secret"), "base64") ); } + + notifyJoin(user: ControllerUser, instance_id: number) { + user.instances.add(instance_id); + this.onlineUsers.add(user); + } + + notifyLeave(user: ControllerUser, instance_id: number) { + user.instances.delete(instance_id); + if (!user.instances.size) { + this.onlineUsers.delete(user); + } + } } diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 77afb9e7..ecacb86e 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1438,19 +1438,19 @@ roleCommands.add(new lib.Command({ role.description = args.description; } if (args.addPerms) { - role.permissions = role.permissions.concat(args.addPerms); + for (const perm of args.addPerms) { + role.permissions.add(perm); + } } if (args.removePerms) { - let perms = new Set(role.permissions); for (let perm of args.removePerms) { - perms.delete(perm); + role.permissions.delete(perm); } - role.permissions = [...perms]; } if (args.setPerms !== undefined) { - role.permissions = args.setPerms; + role.permissions = new Set(args.setPerms); } - await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, role.permissions)); + await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions])); if (args.grantDefault) { await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id)); @@ -1480,8 +1480,10 @@ userCommands.add(new lib.Command({ handler: async function(args: { name: string, instanceStats: boolean }, control: Control) { let user = await control.send(new lib.UserGetRequest(args.name)); Object.assign(user, user.playerStats); + // @ts-expect-error Terrible hack delete user.playerStats; let instanceStats = user.instanceStats; + // @ts-expect-error Terrible hack delete user.instanceStats; print(asTable(Object.entries(user).map(([property, value]) => ({ property, value })))); @@ -1516,13 +1518,20 @@ userCommands.add(new lib.Command({ if (args.stats) { Object.assign(user, user.playerStats); } + // @ts-expect-error Terrible hack delete user.playerStats; + // @ts-expect-error Terrible hack delete user.isDeleted; + // @ts-expect-error Terrible hack delete user.banReason; + // @ts-expect-error Terrible hack delete user.instanceStats; if (!args.attributes) { + // @ts-expect-error Terrible hack delete user.isAdmin; + // @ts-expect-error Terrible hack delete user.isWhitelisted; + // @ts-expect-error Terrible hack delete user.isBanned; } } diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 18cbc868..9e06d1db 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -7,11 +7,10 @@ export * from "./src/helpers"; export * from "./src/ini"; export * from "./src/link"; export * from "./src/logging"; +export * from "./src/permissions"; export * from "./src/plugin"; export * from "./src/schema"; -export * from "./src/users"; export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; -export { default as PlayerStats } from "./src/PlayerStats"; export { default as RateLimiter } from "./src/RateLimiter"; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 4740305f..8f6e6872 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -18,16 +18,15 @@ export * from "./src/link"; export * from "./src/logging"; export * from "./src/logging_utils"; export * from "./src/lua_tools"; +export * from "./src/permissions"; export * from "./src/plugin"; export * from "./src/plugin_loader"; export * from "./src/prometheus"; export * from "./src/schema"; export * from "./src/shared_commands"; export * from "./src/stream"; -export * from "./src/users"; export * from "./src/zip_ops"; export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; -export { default as PlayerStats } from "./src/PlayerStats"; export { default as RateLimiter } from "./src/RateLimiter"; diff --git a/packages/lib/src/command.ts b/packages/lib/src/command.ts index ea5eea11..2a55c22e 100644 --- a/packages/lib/src/command.ts +++ b/packages/lib/src/command.ts @@ -253,7 +253,7 @@ export async function resolveModPack(client: Link, modPackName: string) { export async function retrieveRole(client: Link, roleName: string) { let roles = await client.sendTo("controller", new libData.RoleListRequest()); - let resolvedRole: libData.RawRole | undefined; + let resolvedRole: libData.Role | undefined; if (/^-?\d+$/.test(roleName)) { let roleId = parseInt(roleName, 10); for (let role of roles) { diff --git a/packages/lib/src/data/Permission.ts b/packages/lib/src/data/Permission.ts new file mode 100644 index 00000000..28ace0ca --- /dev/null +++ b/packages/lib/src/data/Permission.ts @@ -0,0 +1,23 @@ +import { Static, Type } from "@sinclair/typebox"; + +/** + * Represents a permission that can be granted + */ +export default class Permission { + constructor( + public name: string, + public title: string, + public description: string, + public grantByDefault?: boolean, + ) { } + + static jsonSchema = Type.Object({ + name: Type.String(), + title: Type.String(), + description: Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.name, json.title, json.description); + } +} diff --git a/packages/lib/src/PlayerStats.ts b/packages/lib/src/data/PlayerStats.ts similarity index 100% rename from packages/lib/src/PlayerStats.ts rename to packages/lib/src/data/PlayerStats.ts diff --git a/packages/lib/src/data/Role.ts b/packages/lib/src/data/Role.ts new file mode 100644 index 00000000..6356def8 --- /dev/null +++ b/packages/lib/src/data/Role.ts @@ -0,0 +1,47 @@ +import { Static, Type } from "@sinclair/typebox"; +import { permissions as globalPermissions } from "../permissions"; + +/** + * Represents a collection of granted permissions + */ +export default class Role { + constructor( + public id: number, + public name: string, + public description: string, + public permissions: Set, + ) { } + + static jsonSchema = Type.Object({ + id: Type.Number(), + name: Type.String(), + description: Type.String(), + permissions: Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this( + json.id, + json.name, + json.description, + new Set(json.permissions), + ); + } + + toJSON() { + return { + id: this.id, + name: this.name, + description: this.description, + permissions: [...this.permissions], + }; + } + + grantDefaultPermissions() { + for (let permission of globalPermissions.values()) { + if (permission.grantByDefault) { + this.permissions.add(permission.name); + } + } + } +} diff --git a/packages/lib/src/data/User.ts b/packages/lib/src/data/User.ts new file mode 100644 index 00000000..c58919a1 --- /dev/null +++ b/packages/lib/src/data/User.ts @@ -0,0 +1,153 @@ +import { Static, Type } from "@sinclair/typebox"; + +import PlayerStats from "./PlayerStats"; + +/** + * Represeents a user in the cluster + * + * Holds data about a Factorio user in the cluster. + */ +export default class User { + constructor( + /** Factorio user name. */ + public name: string, + /** Roles this user has */ + public roleIds = new Set(), + /** Instances this user is online on. */ + public instances = new Set(), + /** True if the user is promoted to admin on the Factorio instances. */ + public isAdmin = false, + /** True if the user is banned from Factorio instances. */ + public isBanned = false, + /** True if the user is whitelisted on the Factorio instances. */ + public isWhitelisted = false, + /** Reason for being banned. Ignored if isBanned is false. */ + public banReason = "", + /** True if this user object has been removed from the cluster. */ + public isDeleted = false, + /** Combined statistics for the player this user account is tied to. */ + public playerStats = new PlayerStats(), + /** Per instance statistics for the player this user account is tied to. */ + public instanceStats = new Map(), + ) { + } + + static jsonSchema = Type.Object({ + name: Type.String(), + roles: Type.Optional(Type.Array(Type.Integer())), + instances: Type.Optional(Type.Array(Type.Integer())), + is_admin: Type.Optional(Type.Boolean()), + is_banned: Type.Optional(Type.Boolean()), + is_whitelisted: Type.Optional(Type.Boolean()), + ban_reason: Type.Optional(Type.String()), + is_deleted: Type.Optional(Type.Boolean()), + instance_stats: Type.Optional( + Type.Array( + Type.Tuple([Type.Integer(), PlayerStats.jsonSchema]), + ) + ), + }); + + static fromJSON(json: Static, _hack?: any) { + const roleIds = new Set(json.roles ?? []); + const instanceStats = new Map( + (json.instance_stats ?? []).map( + ([id, stats]) => [id, PlayerStats.fromJSON(stats)] + ) + ); + const playerStats = User._calculatePlayerStats(instanceStats); + return new this( + json.name, + roleIds, + new Set(json.instances), + json.is_admin, + json.is_banned, + json.is_whitelisted, + json.ban_reason, + json.is_deleted, + playerStats, + instanceStats, + ); + } + + toJSON() { + let json: Static = { + name: this.name, + }; + + if (this.roleIds.size) { + json.roles = [...this.roleIds]; + } + + if (this.instances.size) { + json.instances = [...this.instances]; + } + + if (this.isAdmin) { + json.is_admin = true; + } + + if (this.isWhitelisted) { + json.is_whitelisted = true; + } + + if (this.isBanned) { + json.is_banned = true; + } + + if (this.banReason) { + json.ban_reason = this.banReason; + } + + if (this.isDeleted) { + json.is_deleted = this.isDeleted; + } + + if (this.instanceStats.size) { + json.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]); + } + + return json; + } + + recalculatePlayerStats() { + this.playerStats = User._calculatePlayerStats(this.instanceStats); + } + + static _calculatePlayerStats(instanceStatsMap: Map) { + let playerStats = new PlayerStats(); + for (let instanceStats of instanceStatsMap.values()) { + if ( + instanceStats.lastJoinAt + && (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt) + ) { + playerStats.lastJoinAt = instanceStats.lastJoinAt; + } + if ( + instanceStats.lastLeaveAt + && (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt) + ) { + playerStats.lastLeaveAt = instanceStats.lastLeaveAt; + playerStats.lastLeaveReason = instanceStats.lastLeaveReason; + } + playerStats.joinCount += instanceStats.joinCount; + playerStats.onlineTimeMs += instanceStats.onlineTimeMs; + } + return playerStats; + } +} + +export interface IControllerUser extends User { + /** + * Check if a given permission is granted + * + * Checks the roles the user is member of for one that grants the given + * permission. If the permission is not granted for the user a + * "Permission denied" error is thrown. + * + * @param permission - The permission to check for. + * @throws {Error} If the given permission does not exist. + * @throws {libErrors.PermissionError} if the user does noh have the given permission. + */ + checkPermission(permission: string): void +} diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index 07fd480b..86668cdc 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -7,6 +7,10 @@ export { default as ExportManifest } from "./ExportManifest"; export { default as ModInfo } from "./ModInfo"; export { default as ModPack, ModSetting, ModRecord } from "./ModPack"; export { default as ModuleInfo } from "./ModuleInfo"; +export { default as Permission } from "./Permission"; +export { default as PlayerStats } from "./PlayerStats"; +export { default as Role } from "./Role"; +export { default as User, IControllerUser } from "./User"; export * from "./composites"; export * from "./messages_core"; export * from "./messages_controller"; diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 608ba06b..c5ce1609 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -1,8 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; -import PlayerStats from "../PlayerStats"; +import PlayerStats from "./PlayerStats"; import { JsonString, StringEnum, jsonArray } from "./composites"; import { RawConfig } from "../config"; -import type { User } from "../users"; +import type { IControllerUser } from "./User"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; @@ -433,7 +433,7 @@ export class InstanceTransferSaveRequest { static type = "request" as const; static src = ["control", "controller"] as const; static dst = ["controller", "host"] as const; - static permission(user: User, message: MessageRequest) { + static permission(user: IControllerUser, message: MessageRequest) { user.checkPermission("core.instance.save.transfer"); if (typeof message.data === "object" && message.data !== null) { const data = message.data as { copy: boolean, sourceName: string, targetName: string }; diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index a524a39f..4e8b658b 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,52 +1,16 @@ import { Type, Static } from "@sinclair/typebox"; -import PlayerStats from "../PlayerStats"; +import Permission from "./Permission"; +import Role from "./Role"; +import User from "./User"; import { JsonNumber, jsonArray } from "./composites"; -export class RawPermission { // TODO refactor into lib/user.Permission - constructor( - public name: string, - public title: string, - public description: string, - ) { } - - static jsonSchema = Type.Object({ - name: Type.String(), - title: Type.String(), - description: Type.String(), - }); - - static fromJSON(json: Static) { - return new this(json.name, json.title, json.description); - } -} - export class PermissionListRequest { declare ["constructor"]: typeof PermissionListRequest; static type = "request" as const; static src = "control" as const; static dst = "controller" as const; static permission = "core.permission.list" as const; - static Response = jsonArray(RawPermission); -} - -export class RawRole { // TODO refactor into lib/user.Role - constructor( - public id: number, - public name: string, - public description: string, - public permissions: string[], - ) { } - - static jsonSchema = Type.Object({ - id: Type.Integer(), - name: Type.String(), - description: Type.String(), - permissions: Type.Array(Type.String()), - }); - - static fromJSON(json: Static) { - return new this(json.id, json.name, json.description, json.permissions); - } + static Response = jsonArray(Permission); } export class RoleListRequest { @@ -55,7 +19,7 @@ export class RoleListRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.role.list" as const; - static Response = jsonArray(RawRole); + static Response = jsonArray(Role); } export class RoleCreateRequest { @@ -150,75 +114,6 @@ export class RoleDeleteRequest { } } -export class RawUser { // TODO refactor into lib/user.User - constructor( - public name: string, - public roles: number[], - public instances: number[], - public isAdmin?: boolean, - public isBanned?: boolean, - public isWhitelisted?: boolean, - public banReason?: string, - public isDeleted?: boolean, - public playerStats?: PlayerStats, - public instanceStats?: Map, - ) { } - - static jsonSchema = Type.Object({ - name: Type.String(), - roles: Type.Array(Type.Integer()), - instances: Type.Array(Type.Integer()), - isAdmin: Type.Optional(Type.Boolean()), - isBanned: Type.Optional(Type.Boolean()), - isWhitelisted: Type.Optional(Type.Boolean()), - banReason: Type.Optional(Type.String()), - isDeleted: Type.Optional(Type.Boolean()), - playerStats: Type.Optional(PlayerStats.jsonSchema), - instanceStats: Type.Optional( - Type.Array( - Type.Tuple([Type.Integer(), PlayerStats.jsonSchema]), - ) - ), - }); - - static fromJSON(json: Static) { - let playerStats: PlayerStats | undefined; - if (json.playerStats) { - playerStats = PlayerStats.fromJSON(json.playerStats); - } - let instanceStats: Map | undefined; - if (json.instanceStats) { - instanceStats = new Map( - json.instanceStats.map(([id, stats]) => [id, PlayerStats.fromJSON(stats)]) - ); - } - return new this( - json.name, json.roles, json.instances, json.isAdmin, json.isBanned, json.isWhitelisted, json.banReason, - json.isDeleted, playerStats, instanceStats - ); - } - - toJSON() { - const json: Static = { - name: this.name, - roles: this.roles, - instances: this.instances, - isAdmin: this.isAdmin, - isBanned: this.isBanned, - isWhitelisted: this.isWhitelisted, - banReason: this.banReason, - isDeleted: this.isDeleted, - }; - if (this.playerStats) { - json.playerStats = this.playerStats.toJSON(); - } - if (this.instanceStats) { - json.instanceStats = [...this.instanceStats].map(([k, v]) => [k, v.toJSON()]); - } - return json; - } -} - export class UserGetRequest { declare ["constructor"]: typeof UserGetRequest; static type = "request" as const; @@ -238,7 +133,7 @@ export class UserGetRequest { return new this(json.name); } - static Response = RawUser; + static Response = User; } export class UserListRequest { @@ -247,7 +142,7 @@ export class UserListRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.user.list" as const; - static Response = jsonArray(RawUser); + static Response = jsonArray(User); } export class UserCreateRequest { @@ -414,15 +309,15 @@ export class UserUpdateEvent { static permission = "core.user.subscribe" as const; constructor( - public user: RawUser, + public user: User, ) { } static jsonSchema = Type.Object({ - "user": RawUser.jsonSchema, + "user": User.jsonSchema, }); static fromJSON(json: Static) { - return new this(RawUser.fromJSON(json.user)); + return new this(User.fromJSON(json.user)); } get subscriptionChannel() { diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 3b3d88cd..b27cbfe7 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -7,8 +7,9 @@ import { dataClasses } from "./messages"; import { BaseConnector, WebSocketBaseConnector } from "./connectors"; import { strict as assert } from "assert"; import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; -import type { User } from "../users"; -import type { AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent } from "../data"; +import type { + AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser, +} from "../data"; export interface Request { constructor: Partial>> & { @@ -17,7 +18,7 @@ export interface Request { type: "request"; src: AddressType | readonly AddressType[]; dst: AddressType | readonly AddressType[]; - permission?: null | string | ((user: User, message: MessageRequest) => void); + permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void); plugin?: string; Response?: JSONDeserialisable, } @@ -31,7 +32,7 @@ export interface Event { type: "event"; src: AddressType | readonly AddressType[]; dst: AddressType | readonly AddressType[]; - permission?: null | string | ((user: User, message: MessageEvent) => void); + permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void); plugin?: string; } } diff --git a/packages/lib/src/users.ts b/packages/lib/src/permissions.ts similarity index 68% rename from packages/lib/src/users.ts rename to packages/lib/src/permissions.ts index 53bc91f7..28a7cf89 100644 --- a/packages/lib/src/users.ts +++ b/packages/lib/src/permissions.ts @@ -6,22 +6,9 @@ * @author Hornwitser * @module lib/users */ -import * as libErrors from "./errors"; - -import PlayerStats from "./PlayerStats"; +import { Permission, Role } from "./data"; -/** - * Represents a permission that can be granted - */ -export class Permission { - constructor( - public name: string, - public title: string, - public description: string, - public grantByDefault: boolean, - ) { } -} export const permissions = new Map(); /** @@ -70,40 +57,6 @@ export function definePermission({ permissions.set(name, new Permission(name, title, description, grantByDefault)); } -/** - * Represents a collection of granted permissions - */ -export class Role { - id: number; - name: string; - description: string; - permissions: Set; - - constructor(serializedRole: ReturnType) { - this.id = serializedRole.id; - this.name = serializedRole.name; - this.description = serializedRole.description; - this.permissions = new Set(serializedRole.permissions); - } - - serialize() { - return { - id: this.id, - name: this.name, - description: this.description, - permissions: [...this.permissions], - }; - } - - grantDefaultPermissions() { - for (let permission of permissions.values()) { - if (permission.grantByDefault) { - this.permissions.add(permission.name); - } - } - } -} - /** * Ensure the default admin role has full access @@ -116,7 +69,7 @@ export class Role { export function ensureDefaultAdminRole(roles: Map) { let admin = roles.get(0); if (!admin) { - admin = new Role({ + admin = Role.fromJSON({ id: 0, name: "Cluster Admin", description: "Cluster wide administrator.", @@ -140,7 +93,7 @@ export function ensureDefaultAdminRole(roles: Map) { export function ensureDefaultPlayerRole(roles: Map) { let player = roles.get(1); if (!player) { - player = new Role({ + player = Role.fromJSON({ id: 1, name: "Player", description: "Default player role.", @@ -152,188 +105,6 @@ export function ensureDefaultPlayerRole(roles: Map) { } -/** - * Represeents a user in the cluster - * - * Holds data about a Factorio user in the cluster. - */ -export class User { - /** Factorio user name. */ - name: string; - /** Instances this user is online on. */ - instances: Set; - /** Unix time in seconds the user token must be issued after to be valid. */ - tokenValidAfter: number; - /** True if the user is promoted to admin on the Factorio instances. */ - isAdmin: boolean; - /** True if the user is whitelisted on the Factorio instances. */ - isWhitelisted: boolean; - /** True if the user is banned from Factorio instances. */ - isBanned: boolean; - /** Reason for being banned. Ignored if isBanned is false. */ - banReason: string; - /** Roles this user has */ - roles: Set; - /** Per instance statistics for the player this user account is tied to. */ - instanceStats: Map; - /** Combined statistics for the player this user account is tied to. */ - playerStats: PlayerStats; - /** True if this user object has been removed from the cluster. */ - isDeleted: boolean; - - constructor( - serializedUser: ReturnType, - loadedRoles: Map - ) { - this.name = serializedUser.name; - this.instances = new Set(); - this.tokenValidAfter = serializedUser.token_valid_after || 0; - this.isAdmin = Boolean(serializedUser.is_admin); - this.isWhitelisted = Boolean(serializedUser.is_whitelisted); - this.isBanned = Boolean(serializedUser.is_banned); - this.banReason = serializedUser.ban_reason || ""; - this.roles = new Set(); - if (serializedUser.roles) { - for (let roleId of serializedUser.roles) { - let role = loadedRoles.get(roleId); - if (role) { - this.roles.add(role); - } - } - } - this.instanceStats = new Map( - (serializedUser.instance_stats ? serializedUser.instance_stats : []).map( - ([id, stats]) => [id, new PlayerStats(stats)] - ) - ); - this.playerStats = this._calculatePlayerStats(); - this.isDeleted = false; - } - - serialize() { - let serialized: { - name: string, - roles?: number[], - token_valid_after?: number, - is_admin?: boolean, - is_whitelisted?: boolean, - is_banned?: boolean, - ban_reason?: string, - instance_stats?: [number, object][], - } = { - name: this.name, - }; - - if (this.roles.size) { - serialized.roles = [...this.roles].map(role => role.id); - } - - if (this.tokenValidAfter) { - serialized.token_valid_after = this.tokenValidAfter; - } - - if (this.isAdmin) { - serialized.is_admin = true; - } - - if (this.isWhitelisted) { - serialized.is_whitelisted = true; - } - - if (this.isBanned) { - serialized.is_banned = true; - } - - if (this.banReason) { - serialized.ban_reason = this.banReason; - } - - if (this.instanceStats.size) { - serialized.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]); - } - - return serialized; - } - - /** - * Invalidate current tokens for the user - * - * Sets the tokenValidAfter property to the current time, which causes - * all currently issued tokens for the user to become invalid. - */ - invalidateToken() { - this.tokenValidAfter = Math.floor(Date.now() / 1000); - } - - /** - * Check if a given permission is granted - * - * Checks the roles the user is member of for one that grants the given - * permission. If the permission is not granted for the user a - * "Permission denied" error is thrown. - * - * @param permission - The permission to check for. - * @throws {Error} If the given permission does not exist. - * @throws {libErrors.PermissionError} if the user does noh have the given permission. - */ - checkPermission(permission: string) { - if (!permissions.has(permission)) { - throw new Error(`permission ${permission} does not exist`); - } - - for (let role of this.roles) { - if (role.permissions.has("core.admin") || role.permissions.has(permission)) { - return; - } - } - - throw new libErrors.PermissionError("Permission denied"); - } - - notifyJoin(instance_id: number) { - this.instances.add(instance_id); - User.onlineUsers.add(this); - } - - notifyLeave(instance_id: number) { - this.instances.delete(instance_id); - if (!this.instances.size) { - User.onlineUsers.delete(this); - } - } - - recalculatePlayerStats() { - this.playerStats = this._calculatePlayerStats(); - } - - _calculatePlayerStats() { - let playerStats = new PlayerStats(); - for (let instanceStats of this.instanceStats.values()) { - if ( - instanceStats.lastJoinAt - && (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt) - ) { - playerStats.lastJoinAt = instanceStats.lastJoinAt; - } - if ( - instanceStats.lastLeaveAt - && (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt) - ) { - playerStats.lastLeaveAt = instanceStats.lastLeaveAt; - playerStats.lastLeaveReason = instanceStats.lastLeaveReason; - } - playerStats.joinCount += instanceStats.joinCount; - playerStats.onlineTimeMs += instanceStats.onlineTimeMs; - } - return playerStats; - } - - /** - * Set of users currently online in the cluster. - */ - static onlineUsers = new Set(); -} - // Definitions for the built in permissions used in Clusterio. // description should answer "this permission allows you to ___" definePermission({ diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index f9baea7d..2f6b71a9 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -5,7 +5,7 @@ import * as libHelpers from "./helpers"; import type { Logger } from "./logging"; import type { PluginConfigGroup } from "./config"; -import type PlayerStats from "./PlayerStats"; +import type { PlayerStats } from "./data"; // TODO Add proper typing for plugins /* Used to define the export of info.ts from plugins */ diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index ab7b4c7f..7a176f55 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -1,7 +1,6 @@ import { Type, Static } from "@sinclair/typebox"; import { Link, Event, EventClass, RequestHandler, WebSocketClientConnector, WebSocketBaseConnector } from "./link"; -import { Address, MessageRequest } from "./data"; -import { User } from "./users"; +import { Address, MessageRequest, IControllerUser } from "./data"; export type SubscriptionRequestHandler = RequestHandler | null>; export type EventSubscriberCallback = (value: T) => void; @@ -69,7 +68,7 @@ export class SubscriptionRequest { static src = ["control", "instance"] as const; static dst = "controller" as const; static Response = SubscriptionResponse; - static permission(user: User, message: MessageRequest) { + static permission(user: IControllerUser, message: MessageRequest) { if (typeof message.data === "object" && message.data !== null) { const data = message.data as Static; const entry = Link._eventsByName.get(data[0]); diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 19137389..d4d9015f 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -13,7 +13,7 @@ import PluginExtra from "./PluginExtra"; import { notifyErrorHandler } from "../util/notify"; -export type RawRoleState = Partial & { +export type RawRoleState = Partial & { loading?: boolean; missing?: boolean; present?: boolean; @@ -76,7 +76,7 @@ export default function RoleViewPage() { description: role["description"], permissions: { ...Object.fromEntries([...lib.permissions.values()].map(perm => [ - perm.name, role.permissions!.includes(perm.name), + perm.name, [...role.permissions!].includes(perm.name), ])), }, }; diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index c2d03708..5c257289 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -63,7 +63,7 @@ export default function RolesPage() { let control = useContext(ControlContext); let navigate = useNavigate(); - let [roles, setRoles] = useState([]); + let [roles, setRoles] = useState([]); useEffect(() => { control.send(new lib.RoleListRequest()).then(newRoles => { diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index db9f55e7..cc0150b9 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -35,7 +35,7 @@ export default function UserViewPage() { let control = useContext(ControlContext); let [instanceList] = useInstanceList(); let [user] = useUser(userName); - let [roles, setRoles] = useState>(new Map()); + let [roles, setRoles] = useState>(new Map()); let [form] = Form.useForm(); let [rolesDirty, setRolesDirty] = useState(false); let [banReasonDirty, setBanReasonDirty] = useState(false); @@ -78,7 +78,7 @@ export default function UserViewPage() { let roleSelector = (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, ]} @@ -181,7 +181,7 @@ export default function UserViewPage() { {account.hasPermission("core.user.update_roles") ? roleSelector - : [...user.roles!].map(id => { + : [...user.roleIds!].map(id => { roles ? (roles.get(id) || { name: id }).name : id }) } diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index 9ed95f31..9942fce0 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -82,7 +82,7 @@ export default function UsersPage() { { title: "Name", key: "name", - render: (user: lib.RawUser) => + render: (user: lib.User) => {user.name} {user.isAdmin && Admin} @@ -96,14 +96,14 @@ export default function UsersPage() { { title: "Roles", key: "roles", - render: (user: lib.RawUser) => ( - user.roles.map(id => {(roles.get(id) || { name: id }).name}) + render: (user: lib.User) => ( + [...user.roleIds].map(id => {(roles.get(id) || { name: id }).name}) ), }, { title: "Online time", key: "onlineTime", - render: (user: lib.RawUser) => (user.playerStats?.onlineTimeMs + render: (user: lib.User) => (user.playerStats?.onlineTimeMs ? formatDuration(user.playerStats.onlineTimeMs) : null), sorter: (a, b) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), @@ -112,7 +112,7 @@ export default function UsersPage() { { title: "Last seen", key: "lastSeen", - render: (user: lib.RawUser) => formatLastSeen(user), + render: (user: lib.User) => formatLastSeen(user), sorter: (a, b) => sortLastSeen(a, b), responsive: ["lg"], }, diff --git a/packages/web_ui/src/model/user.tsx b/packages/web_ui/src/model/user.tsx index 069f15f0..6ed8431b 100644 --- a/packages/web_ui/src/model/user.tsx +++ b/packages/web_ui/src/model/user.tsx @@ -26,7 +26,7 @@ function calculateLastSeen(user: RawUserState, instanceId?: number) { } export function formatLastSeen(user: RawUserState, instanceId?: number) { - if (user.instances && user.instances.some(id => instanceId === undefined || id === instanceId)) { + if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) { return Online; } let lastSeen = calculateLastSeen(user, instanceId); @@ -38,7 +38,7 @@ export function formatLastSeen(user: RawUserState, instanceId?: number) { export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA?: number, instanceIdB?: number) { function epoch(user:RawUserState, instanceId?: number) { - return user.instances && user.instances.some(id => instanceId === undefined || id === instanceId); + return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId); } let epochA = epoch(userA, instanceIdA); @@ -52,7 +52,7 @@ export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA return lastSeenA - lastSeenB; } -export type RawUserState = Partial & { +export type RawUserState = Partial & { loading?: boolean; present?: boolean; missing?: boolean; @@ -78,7 +78,7 @@ export function useUser(name: string): [RawUserState, () => void] { } updateUser(); - function updateHandler(newUser: lib.RawUser) { + function updateHandler(newUser: lib.User) { setUser({ ...newUser, present: true }); } @@ -93,7 +93,7 @@ export function useUser(name: string): [RawUserState, () => void] { export function useUserList() { let control = useContext(ControlContext); - let [userList, setUserList] = useState([]); + let [userList, setUserList] = useState([]); function updateUserList() { control.send(new lib.UserListRequest()).then(users => { @@ -106,7 +106,7 @@ export function useUserList() { useEffect(() => { updateUserList(); - function updateHandler(newUser: lib.RawUser) { + function updateHandler(newUser: lib.User) { setUserList(oldList => { let newList = oldList.concat(); let index = newList.findIndex(u => u.name === newUser.name); diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index a61386d3..02b24fbd 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -10,7 +10,7 @@ type instanceHandler = (instanceDetails: lib.InstanceDetails) => void; type saveListHandler = (saveListEvent: lib.InstanceSaveListUpdateEvent) => void; type modPackHandler = (modPack: lib.ModPack) => void; type modInfoHandler = (modInfo: lib.ModInfo) => void; -type userHandler = (rawUser: lib.RawUser) => void; +type userHandler = (rawUser: lib.User) => void; type logHandler = (info: { level:string, message:string }) => void; /** @@ -80,7 +80,7 @@ export class Control extends lib.Link { lib.ModUpdateEvent, event => event.mod, ); - userUpdate = new lib.EventSubscriber( + userUpdate = new lib.EventSubscriber( lib.UserUpdateEvent, event => event.user, ); diff --git a/test/controller/ControllerUser.js b/test/controller/ControllerUser.js new file mode 100644 index 00000000..a7fe97ac --- /dev/null +++ b/test/controller/ControllerUser.js @@ -0,0 +1,50 @@ +"use strict"; +const assert = require("assert").strict; +const { ControllerUser } = require("@clusterio/controller"); +const lib = require("@clusterio/lib"); + +describe("controller/src/ControllerUser", function() { + describe("class ControllerUser", function() { + class MockUserManager { + roles = new Map([ + [1, new lib.Role(1, "a", "a role", new Set(["core.admin"]))], + [2, new lib.Role(2, "b", "b role", new Set(["user-test"]))], + ]); + } + const userManager = new MockUserManager(); + + it("should round trip serialize", function() { + function test_roundtrip(serialized) { + let user = ControllerUser.fromJSON(serialized, userManager); + let user_serialized = user.toJSON(true); + assert.deepEqual(user_serialized, serialized); + let user_deserialized = ControllerUser.fromJSON(user_serialized, userManager); + assert.deepEqual(user_deserialized, user); + } + + test_roundtrip({ name: "admin", roles: [1] }); + test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); + test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); + test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); + test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); + }); + describe(".checkPermission()", function() { + it("should correctly resolve permissions", function() { + lib.definePermission({ name: "user-test", title: "Test", description: "User Test" }); + let a = ControllerUser.fromJSON({ name: "admin", roles: [1] }, userManager); + let b = ControllerUser.fromJSON({ name: "user", roles: [2] }, userManager); + let c = ControllerUser.fromJSON({ name: "null", roles: [] }, userManager); + + a.checkPermission("core.control.connect"); + assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied")); + assert.throws(() => c.checkPermission("core.control.connect"), new Error("Permission denied")); + + a.checkPermission("user-test"); + b.checkPermission("user-test"); + assert.throws(() => c.checkPermission("user-test"), new Error("Permission denied")); + + assert.throws(() => a.checkPermission("invalid"), new Error("permission invalid does not exist")); + }); + }); + }); +}); diff --git a/test/controller/UserManager.js b/test/controller/UserManager.js new file mode 100644 index 00000000..5294d551 --- /dev/null +++ b/test/controller/UserManager.js @@ -0,0 +1,35 @@ +"use strict"; +const assert = require("assert").strict; +const { ControllerUser, UserManager } = require("@clusterio/controller"); + +describe("controller/src/UserManager", function() { + describe("class ControllerUser", function() { + const userManager = new UserManager({}); + it("should track online users", function() { + let user = ControllerUser.fromJSON({ name: "admin", roles: [1] }); + userManager.users.set(user.name, user); + assert(!userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set()); + + userManager.notifyJoin(user, 12); + assert(userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12])); + + userManager.notifyJoin(user, 8); + assert(userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12, 8])); + + userManager.notifyLeave(user, 11); + assert(userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([12, 8])); + + userManager.notifyLeave(user, 12); + assert(userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set([8])); + + userManager.notifyLeave(user, 8); + assert(!userManager.onlineUsers.has(user)); + assert.deepEqual(user.instances, new Set()); + }); + }); +}); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 9c235745..af982cea 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -1034,7 +1034,7 @@ describe("Integration of Clusterio", function() { let tempRole = roles.find(role => role.name === "temp"); assert.deepEqual( tempRole, - new lib.RawRole(5, "temp", "A temp role", ["core.control.connect"]) + new lib.Role(5, "temp", "A temp role", new Set(["core.control.connect"])) ); }); }); @@ -1045,21 +1045,21 @@ describe("Integration of Clusterio", function() { await execCtl(`role edit temp ${args}`); let roles = await getControl().send(new lib.RoleListRequest()); let newRole = roles.find(role => role.name === "new"); - assert.deepEqual(newRole, new lib.RawRole(5, "new", "A new role", [])); + assert.deepEqual(newRole, new lib.Role(5, "new", "A new role", new Set())); }); it("should add permissions with --add-perms", async function() { let args = "--name new --add-perms core.host.list core.instance.list"; await execCtl(`role edit new ${args}`); let roles = await getControl().send(new lib.RoleListRequest()); let newRole = roles.find(role => role.name === "new"); - assert.deepEqual(newRole.permissions, ["core.host.list", "core.instance.list"]); + assert.deepEqual(newRole.permissions, new Set(["core.host.list", "core.instance.list"])); }); it("should remove permissions with --remove-perms", async function() { let args = "--name new --remove-perms core.host.list"; await execCtl(`role edit new ${args}`); let roles = await getControl().send(new lib.RoleListRequest()); let newRole = roles.find(role => role.name === "new"); - assert.deepEqual(newRole.permissions, ["core.instance.list"]); + assert.deepEqual(newRole.permissions, new Set(["core.instance.list"])); }); it("should grant default permissions with --grant-default", async function() { await execCtl("role edit new --grant-default"); @@ -1126,7 +1126,7 @@ describe("Integration of Clusterio", function() { await execCtl('user set-roles temp "Cluster Admin"'); let users = await getControl().send(new lib.UserListRequest()); let tempUser = users.find(user => user.name === "temp"); - assert.deepEqual(tempUser.roles, [0]); + assert.deepEqual(tempUser.roleIds, new Set([0])); assert.equal(getControl().userUpdates.length, 1); }); diff --git a/test/lib/command.js b/test/lib/command.js index 7fd94d91..82decfea 100644 --- a/test/lib/command.js +++ b/test/lib/command.js @@ -8,7 +8,7 @@ const addr = lib.Address.fromShorthand; describe("lib/command", function() { - let testRole = lib.RawRole.fromJSON({ id: 28, name: "Test Role", description: "Test", permissions: [] }); + let testRole = lib.Role.fromJSON({ id: 28, name: "Test Role", description: "Test", permissions: [] }); let [controlConnector, controllerConnector] = lib.VirtualConnector.makePair( addr({ controlId: 1}), addr("controller") @@ -21,7 +21,7 @@ describe("lib/command", function() { testController.handle( lib.InstanceDetailsListRequest, () => [new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped")] ); - testController.handle(lib.RoleListRequest, () => [testRole]); + testController.handle(lib.RoleListRequest, () => [testRole.toJSON()]); describe("resolveHost", function() { it("should pass an integer like string back", async function() { diff --git a/test/lib/data/Role.ts b/test/lib/data/Role.ts new file mode 100644 index 00000000..8cc86045 --- /dev/null +++ b/test/lib/data/Role.ts @@ -0,0 +1,28 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + + +describe("lib/data/Role", function() { + describe("class Role", function() { + it("should round trip serialize", function() { + let orig = lib.Role.fromJSON({ id: 11, name: "Role", description: "My Role", permissions: ["test"] }); + let copy = lib.Role.fromJSON(orig.toJSON()); + assert.deepEqual(copy, orig); + }); + + describe(".grantDefaultRoles()", function() { + it("should only grant permissions with grantByDefault", function() { + let role = lib.Role.fromJSON({ id: 11, name: "Role", description: "My Role" }); + role.grantDefaultPermissions(); + assert(role.permissions.size > 0, "No permissions were granted"); + for (let permission of role.permissions) { + assert( + lib.permissions.get(permission).grantByDefault === true, + "Non-default permission granted" + ); + } + }); + }); + }); +}); diff --git a/test/lib/data/User.js b/test/lib/data/User.js new file mode 100644 index 00000000..fa4d1683 --- /dev/null +++ b/test/lib/data/User.js @@ -0,0 +1,67 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + + +describe("lib/data/User", function() { + describe("class User", function() { + it("should round trip serialize", function() { + function test_roundtrip(serialized) { + let user = lib.User.fromJSON(serialized); + let user_serialized = user.toJSON(); + assert.deepEqual(user_serialized, serialized); + let user_deserialized = lib.User.fromJSON(user_serialized); + assert.deepEqual(user_deserialized, user); + } + + test_roundtrip({ name: "admin", roles: [1] }); + test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); + test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); + test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); + }); + it("should keep invalid roles", function() { + let user = lib.User.fromJSON({ name: "test", roles: [1, 4, 55] }); + assert.equal(user.roleIds.size, 3, "Unexpected count of roles"); + }); + it("should calculate playerStats", function() { + let user = lib.User.fromJSON({ name: "test", roles: [1], instance_stats: [ + [1, { + join_count: 1, + online_time_ms: 60e3, + last_join_at: new Date("2020-05T12:00Z").getTime(), + last_leave_at: new Date("2020-05T12:01Z").getTime(), + last_leave_reason: "quit", + }], + [2, { + join_count: 1, + online_time_ms: 0, + last_join_at: new Date("2020-05T12:02Z").getTime(), + }], + ]}); + + assert.equal(user.playerStats.onlineTimeMs, 60e3); + assert.equal(user.playerStats.joinCount, 2); + assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z")); + assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z")); + assert.equal(user.playerStats.lastLeaveReason, "quit"); + + user.instanceStats.set( + 3, + lib.PlayerStats.fromJSON({ + join_count: 2, + online_time_ms: 120e3, + last_join_at: new Date("2020-05T13:00Z").getTime(), + last_leave_at: new Date("2020-05T13:02Z").getTime(), + last_leave_reason: "afk", + }), + ); + + user.recalculatePlayerStats(); + assert.equal(user.playerStats.onlineTimeMs, 180e3); + assert.equal(user.playerStats.joinCount, 4); + assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T13:00Z")); + assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T13:02Z")); + assert.equal(user.playerStats.lastLeaveReason, "afk"); + }); + }); +}); diff --git a/test/lib/users.js b/test/lib/users.js index 457a01a0..bb636191 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -44,124 +44,4 @@ describe("lib/users", function() { ); }); }); - - describe("class Role", function() { - it("should round trip serialize", function() { - let orig = new lib.Role({ id: 11, name: "Role", description: "My Role", permissions: ["test"] }); - let copy = new lib.Role(orig.serialize()); - assert.deepEqual(copy, orig); - }); - - describe(".grantDefaultRoles()", function() { - it("should only grant permissions with grantByDefault", function() { - let role = new lib.Role({ id: 11, name: "Role", description: "My Role" }); - role.grantDefaultPermissions(); - assert(role.permissions.size > 0, "No permissions were granted"); - for (let permission of role.permissions) { - assert( - lib.permissions.get(permission).grantByDefault === true, - "Non-default permission granted" - ); - } - }); - }); - }); - - describe("class User", function() { - let roles = new Map([ - [1, new lib.Role({ id: 1, name: "a", description: "a role", permissions: ["core.admin"] })], - [2, new lib.Role({ id: 2, name: "b", description: "b role", permissions: ["test"] })], - ]); - it("should round trip serialize", function() { - function test_roundtrip(serialized) { - let user = new lib.User(serialized, roles); - let user_serialized = user.serialize(); - assert.deepEqual(user_serialized, serialized); - let user_deserialized = new lib.User(user_serialized, roles); - assert.deepEqual(user_deserialized, user); - } - - test_roundtrip({ name: "admin", roles: [1] }); - test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); - test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); - test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); - test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); - }); - it("should ignore invalid roles", function() { - let user = new lib.User({ name: "test", roles: [1, 4, 55] }, roles); - assert.equal(user.roles.size, 1, "Unexpected count of roles"); - }); - it("should track online users", function() { - let user = new lib.User({ name: "admin", roles: [1] }, roles); - assert(!lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set()); - - user.notifyJoin(12); - assert(lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12])); - - user.notifyJoin(8); - assert(lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12, 8])); - - user.notifyLeave(11); - assert(lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12, 8])); - - user.notifyLeave(12); - assert(lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([8])); - - user.notifyLeave(8); - assert(!lib.User.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set()); - }); - it("should calculate playerStats", function() { - let user = new lib.User({ name: "test", roles: [1], instance_stats: [ - [1, { - join_count: 1, - online_time_ms: 60e3, - last_join_at: new Date("2020-05T12:00Z").getTime(), - last_leave_at: new Date("2020-05T12:01Z").getTime(), - last_leave_reason: "quit", - }], - [2, { - join_count: 1, - online_time_ms: 0, - last_join_at: new Date("2020-05T12:02Z").getTime(), - }], - [3, { - join_count: 2, - online_time_ms: 120e3, - last_join_at: new Date("2020-05T11:00Z").getTime(), - last_leave_at: new Date("2020-05T11:02Z").getTime(), - last_leave_reason: "afk", - }], - ]}, roles); - - assert.equal(user.playerStats.onlineTimeMs, 180e3); - assert.equal(user.playerStats.joinCount, 4); - assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z")); - assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z")); - assert.equal(user.playerStats.lastLeaveReason, "quit"); - }); - - describe(".checkPermission()", function() { - it("should correctly resolve permissions", function() { - let a = new lib.User({ name: "admin", roles: [1] }, roles); - let b = new lib.User({ name: "user", roles: [2] }, roles); - let c = new lib.User({ name: "null", roles: [] }, roles); - - a.checkPermission("core.control.connect"); - assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied")); - assert.throws(() => c.checkPermission("core.control.connect"), new Error("Permission denied")); - - a.checkPermission("test"); - b.checkPermission("test"); - assert.throws(() => c.checkPermission("test"), new Error("Permission denied")); - - assert.throws(() => a.checkPermission("invalid"), new Error("permission invalid does not exist")); - }); - }); - }); }); diff --git a/test/mock.js b/test/mock.js index 7662ca22..a00729c9 100644 --- a/test/mock.js +++ b/test/mock.js @@ -6,6 +6,7 @@ const express = require("express"); const lib = require("@clusterio/lib"); const UserManager = require("@clusterio/controller/dist/src/UserManager").default; +const ControllerUser = require("@clusterio/controller/dist/src/ControllerUser").default; const addr = lib.Address.fromShorthand; @@ -167,14 +168,14 @@ class MockController { this.userManager = new UserManager(this.config); this.userManager.roles = new Map([ - [0, new lib.Role({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })], - [1, new lib.Role({ id: 1, name: "Player", description: "player", permissions: [] })], + [0, lib.Role.fromJSON({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })], + [1, lib.Role.fromJSON({ id: 1, name: "Player", description: "player", permissions: [] })], ]); this.userManager.roles.get(1).grantDefaultPermissions(); this.userManager.users = new Map([ - ["test", new lib.User({ name: "test", roles: [0, 1] }, this.userManager.roles)], - ["player", new lib.User({ name: "player", roles: [1] }, this.userManager.roles)], + ["test", ControllerUser.fromJSON({ name: "test", roles: [0, 1] }, this.userManager)], + ["player", ControllerUser.fromJSON({ name: "player", roles: [1] }, this.userManager)], ]); this.instances = new Map(); this.hosts = new Map();