const EventEmitter = require('events'); const { Organisations } = require('@dougal/organisations'); function randomUUID () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } class User extends EventEmitter { // Valid field names static fields = [ "ip", "host", "name", "email", "description", "colour", "active", "organisations", "meta" ] static validUUID (str) { const uuidv4Rx = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidv4Rx.test(str); } static validIPv4 (str) { const ipv4Rx = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([0-9]|[1-2][0-9]|3[0-2]))?$/; return ipv4Rx.test(str); } static validIPv6 (str) { const ipv6Rx = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:((?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?))|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|(2[0-4][0-9]|[01]?[0-9][0-9]?))))$/; return ipv6Rx.test(str); } static validHostname (str) { const hostnameRx = /^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? this[f] = data?.[f] ); this.values.id = data?.id ?? randomUUID(); this.values.active = !!this.active; this.values.hash = data?.hash; this.values.password = data?.password; this.values.organisations = new Organisations(data?.organisations); this.#updateTimestamp(data?.last_modified); } /* * Getters */ get id () { return this.values.id } get ip () { return this.values.ip } get host () { return this.values.host } get name () { return this.values.name } get email () { return this.values.email } get description () { return this.values.description } get colour () { return this.values.colour } get active () { return this.values.active } get organisations () { return this.values.organisations } get password () { return this.values.password } get timestamp () { return new Date(this.#timestamp) } /* * Setters */ set id (v) { if (typeof v === "undefined") { this.values.id = randomUUID(); } else if (User.validUUID(v)) { this.values.id = v; } else { throw new Error("Invalid ID format (must be UUIDv4)"); } this.emit("changed", "id", this.values.id); this.#updateTimestamp(); } set ip (v) { if (User.validIPv4(v) || User.validIPv6(v) || typeof v === "undefined") { this.values.ip = v; } else { throw new Error("Invalid IP address or subnet"); } this.emit("changed", "ip", this.values.ip); this.#updateTimestamp(); } set host (v) { if (User.validHostname(v) || typeof v === "undefined") { this.values.host = v; } else { throw new Error("Invalid hostname"); } this.emit("changed", "host", this.values.host); this.#updateTimestamp(); } set name (v) { this.#setString("name", v); } set email (v) { // TODO should validate, buy hey! this.#setString("email", v); } set description (v) { this.#setString("description", v); } set colour (v) { this.#setString("colour", v); } set active (v) { this.values.active = !!v; this.emit("changed", "active", this.values.active); this.#updateTimestamp(); } set organisations (v) { this.values.organisations = new Organisations(v); this.emit("changed", "organisations", this.values.organisations); this.#updateTimestamp(); } set password (v) { this.values.password = v; this.emit("changed", "password", this.values.password); this.#updateTimestamp(); } /* * Validation methods */ get errors () { let err = []; if (!this.id) err.push("ERR_NO_ID"); if (!this.name) err.push("ERR_NO_NAME"); if (!this.organisations.length) err.push("ERR_NO_ORG"); return err; } get isValid () { return this.errors.length == 0; } /* * Filtering methods */ filter (other) { // const filteredUser = new User(this); const filteredUser = this.#clone(); filteredUser.organisations = this.organisations.filter(other.organisations); return filteredUser; } /** Return users that are visible to me. * * These are users with which at leas one common organisation * with read, write or delete access to. * * If we are wildcarded ("*"), we see everyone. * * If a peer is wildcarded, they can be seen by everone. */ peers (list) { if (this.organisations.value("*")) { return list; } else { return list.filter( user => this.canRead(user) ); // return list.filter( user => // user.organisations.value("*") || // user.organisations.filter(this.organisations).length > 0 // this.organisations.filter(user.organisations).length > 0 // ); } } /** Return users that I can edit * * These users must belong to an organisation * over which I have edit rights. * * If we are edit wildcarded, we can edit everyone. */ editablePeers (list) { const editableOrgs = this.organisations.accessToOperation("edit"); if (editableOrgs.value("*")) { return list; } else { return list.filter( user => this.canEdit(user) ); // editableOrgs.filter(user.organisations).length > 0 // ); } } /* * General methods */ /** Return `true` if we are `other` */ is (other) { return this.id == other.id; } canDo (operation, other) { if (this.organisations.get('*')?.[operation]) return true; if (other instanceof User) { return other.organisations.names().some(name => this.organisations.get(name)?.[operation]); } else if (other instanceof Organisations) { return other.accessToOperation(operation).names().some(name => this.organisations.get(name)?.[operation]); } else if (other?.organisations) { return this.canDo(operation, new Organisations(other.organisations)); } else if (other instanceof Object) { return this.canDo(operation, new Organisations(other)); } return false; } canRead (other) { return this.canDo("read", other); } canWrite (other) { return this.canDo("write", other); } canEdit (other) { return this.canDo("edit", other); } /** Perform an edit on another user * * Syntax: user.edit(other).to(another); * * Applies to `other` the changes described in `another` * that are permitted to `user`. The argument `another` * must be a plain object (not a `User` instance) with * only the properties that are to be changed. * * NOTE: Organisations are not merged, they are overwritten * and then filtered to ensure that the edited user does not * gain more privileges than those granted to the editing * user. * * Example: * * // This causes user test77 to set user x23 to * // inactive * test77.edit(x23).to({active: false}) */ edit (other) { if (this.canEdit(other)) { return { to: (another) => { const newUser = Object.assign(this.#clone(other), another); return newUser.filter(this); } } } // Do not fail or throw but return undefined } /** Create a new user similar to us except it doesn't have `edit` rights * by default */ spawn (init = {}, mask = {read: true, write: true, edit: false}) { // const user = new User(init); const user = this.#clone(init); user.organisations = this.organisations.accessToOperation("edit").disableOperation("edit"); user.organisations.overlord = this.organisations; return user; } /* * Conversion and presentation methods */ toJSON () { return { id: this.id, ip: this.ip, host: this.host, name: this.name, email: this.email, description: this.description, colour: this.colour, active: this.active, organisations: this.organisations.toJSON(), password: this.password } } toString (replacer, space) { return JSON.stringify(this.toJSON(), replacer, space); } } if (typeof module !== 'undefined' && module.exports) { module.exports = User; // CJS export } // ESM export if (typeof exports !== 'undefined' && !exports.default) { exports.default = User; // ESM export }