249 lines
8.6 KiB
JavaScript
249 lines
8.6 KiB
JavaScript
import Document from "../abstract/document.mjs";
|
|
import * as fields from "../data/fields.mjs";
|
|
import * as CONST from "../constants.mjs";
|
|
import {isEmpty, mergeObject} from "../utils/helpers.mjs";
|
|
import {isValidId} from "../data/validators.mjs";
|
|
import Color from "../utils/color.mjs";
|
|
import BaseActor from "./actor.mjs";
|
|
|
|
/**
|
|
* @typedef {import("./_types.mjs").UserData} UserData
|
|
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
|
*/
|
|
|
|
/**
|
|
* The User Document.
|
|
* Defines the DataSchema and common behaviors for a User which are shared between both client and server.
|
|
* @mixes UserData
|
|
*/
|
|
export default class BaseUser extends Document {
|
|
/**
|
|
* Construct a User document using provided data and context.
|
|
* @param {Partial<UserData>} data Initial data from which to construct the User
|
|
* @param {DocumentConstructionContext} context Construction context options
|
|
*/
|
|
constructor(data, context) {
|
|
super(data, context);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Model Configuration */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static metadata = Object.freeze(mergeObject(super.metadata, {
|
|
name: "User",
|
|
collection: "users",
|
|
label: "DOCUMENT.User",
|
|
labelPlural: "DOCUMENT.Users",
|
|
permissions: {
|
|
create: this.#canCreate,
|
|
update: this.#canUpdate,
|
|
delete: this.#canDelete
|
|
},
|
|
schemaVersion: "12.324",
|
|
}, {inplace: false}));
|
|
|
|
/** @override */
|
|
static LOCALIZATION_PREFIXES = ["USER"];
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static defineSchema() {
|
|
return {
|
|
_id: new fields.DocumentIdField(),
|
|
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
|
role: new fields.NumberField({required: true, choices: Object.values(CONST.USER_ROLES),
|
|
initial: CONST.USER_ROLES.PLAYER, readonly: true}),
|
|
password: new fields.StringField({required: true, blank: true}),
|
|
passwordSalt: new fields.StringField(),
|
|
avatar: new fields.FilePathField({categories: ["IMAGE"]}),
|
|
character: new fields.ForeignDocumentField(BaseActor),
|
|
color: new fields.ColorField({required: true, nullable: false,
|
|
initial: () => Color.fromHSV([Math.random(), 0.8, 0.8]).css
|
|
}),
|
|
pronouns: new fields.StringField({required: true}),
|
|
hotbar: new fields.ObjectField({required: true, validate: BaseUser.#validateHotbar,
|
|
validationError: "must be a mapping of slots to macro identifiers"}),
|
|
permissions: new fields.ObjectField({required: true, validate: BaseUser.#validatePermissions,
|
|
validationError: "must be a mapping of permission names to booleans"}),
|
|
flags: new fields.ObjectField(),
|
|
_stats: new fields.DocumentStatsField()
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Validate the structure of the User hotbar object
|
|
* @param {object} bar The attempted hotbar data
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
static #validateHotbar(bar) {
|
|
if ( typeof bar !== "object" ) return false;
|
|
for ( let [k, v] of Object.entries(bar) ) {
|
|
let slot = parseInt(k);
|
|
if ( !slot || slot < 1 || slot > 50 ) return false;
|
|
if ( !isValidId(v) ) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Validate the structure of the User permissions object
|
|
* @param {object} perms The attempted permissions data
|
|
* @return {boolean}
|
|
*/
|
|
static #validatePermissions(perms) {
|
|
for ( let [k, v] of Object.entries(perms) ) {
|
|
if ( typeof k !== "string" ) return false;
|
|
if ( k.startsWith("-=") ) {
|
|
if ( v !== null ) return false;
|
|
} else {
|
|
if ( typeof v !== "boolean" ) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Model Properties */
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* A convenience test for whether this User has the NONE role.
|
|
* @type {boolean}
|
|
*/
|
|
get isBanned() {
|
|
return this.role === CONST.USER_ROLES.NONE;
|
|
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test whether the User has a GAMEMASTER or ASSISTANT role in this World?
|
|
* @type {boolean}
|
|
*/
|
|
get isGM() {
|
|
return this.hasRole(CONST.USER_ROLES.ASSISTANT);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Test whether the User is able to perform a certain permission action.
|
|
* The provided permission string may pertain to an explicit permission setting or a named user role.
|
|
*
|
|
* @param {string} action The action to test
|
|
* @return {boolean} Does the user have the ability to perform this action?
|
|
*/
|
|
can(action) {
|
|
if ( action in CONST.USER_PERMISSIONS ) return this.hasPermission(action);
|
|
return this.hasRole(action);
|
|
}
|
|
|
|
/* ---------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
getUserLevel(user) {
|
|
return CONST.DOCUMENT_OWNERSHIP_LEVELS[user.id === this.id ? "OWNER" : "NONE"];
|
|
}
|
|
|
|
/* ---------------------------------------- */
|
|
|
|
/**
|
|
* Test whether the User has at least a specific permission
|
|
* @param {string} permission The permission name from USER_PERMISSIONS to test
|
|
* @return {boolean} Does the user have at least this permission
|
|
*/
|
|
hasPermission(permission) {
|
|
if ( this.isBanned ) return false;
|
|
|
|
// CASE 1: The user has the permission set explicitly
|
|
const explicit = this.permissions[permission];
|
|
if (explicit !== undefined) return explicit;
|
|
|
|
// CASE 2: Permission defined by the user's role
|
|
const rolePerms = game.permissions[permission];
|
|
return rolePerms ? rolePerms.includes(this.role) : false;
|
|
}
|
|
|
|
/* ----------------------------------------- */
|
|
|
|
/**
|
|
* Test whether the User has at least the permission level of a certain role
|
|
* @param {string|number} role The role name from USER_ROLES to test
|
|
* @param {boolean} [exact] Require the role match to be exact
|
|
* @return {boolean} Does the user have at this role level (or greater)?
|
|
*/
|
|
hasRole(role, {exact = false} = {}) {
|
|
const level = typeof role === "string" ? CONST.USER_ROLES[role] : role;
|
|
if (level === undefined) return false;
|
|
return exact ? this.role === level : this.role >= level;
|
|
}
|
|
|
|
/* ---------------------------------------- */
|
|
/* Model Permissions */
|
|
/* ---------------------------------------- */
|
|
|
|
/**
|
|
* Is a user able to create an existing User?
|
|
* @param {BaseUser} user The user attempting the creation.
|
|
* @param {BaseUser} doc The User document being created.
|
|
* @param {object} data The supplied creation data.
|
|
* @private
|
|
*/
|
|
static #canCreate(user, doc, data) {
|
|
if ( !user.isGM ) return false; // Only Assistants and above can create users.
|
|
// Do not allow Assistants to create a new user with special permissions which might be greater than their own.
|
|
if ( !isEmpty(doc.permissions) ) return user.hasRole(CONST.USER_ROLES.GAMEMASTER);
|
|
return user.hasRole(doc.role);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Is a user able to update an existing User?
|
|
* @param {BaseUser} user The user attempting the update.
|
|
* @param {BaseUser} doc The User document being updated.
|
|
* @param {object} changes Proposed changes.
|
|
* @private
|
|
*/
|
|
static #canUpdate(user, doc, changes) {
|
|
const roles = CONST.USER_ROLES;
|
|
if ( user.role === roles.GAMEMASTER ) return true; // Full GMs can do everything
|
|
if ( user.role === roles.NONE ) return false; // Banned users can do nothing
|
|
|
|
// Non-GMs cannot update certain fields.
|
|
const restricted = ["permissions", "passwordSalt"];
|
|
if ( user.role < roles.ASSISTANT ) restricted.push("name", "role");
|
|
if ( doc.role === roles.GAMEMASTER ) restricted.push("password");
|
|
if ( restricted.some(k => k in changes) ) return false;
|
|
|
|
// Role changes may not escalate
|
|
if ( ("role" in changes) && !user.hasRole(changes.role) ) return false;
|
|
|
|
// Assistant GMs may modify other users. Players may only modify themselves
|
|
return user.isGM || (user.id === doc.id);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Is a user able to delete an existing User?
|
|
* Only Assistants and Gamemasters can delete users, and only if the target user has a lesser or equal role.
|
|
* @param {BaseUser} user The user attempting the deletion.
|
|
* @param {BaseUser} doc The User document being deleted.
|
|
* @private
|
|
*/
|
|
static #canDelete(user, doc) {
|
|
const role = Math.max(CONST.USER_ROLES.ASSISTANT, doc.role);
|
|
return user.hasRole(role);
|
|
}
|
|
}
|