diff --git a/lib/modules/@dougal/user/User.js b/lib/modules/@dougal/user/User.js new file mode 100644 index 0000000..e29ea75 --- /dev/null +++ b/lib/modules/@dougal/user/User.js @@ -0,0 +1,403 @@ +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.canSee(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; + } + + /** Return `true` if we can see `other` + * + * If we are wildcarded, we can see everyone + * + * If not, we must have at least one common organisation + * that we are both members of + */ + canSee (other) { + if (this.organisations.value("*")?.read) { + return true; + } else if (other instanceof User) { + return other.organisations.names().some( name => this.organisations.value(name) ); + } else if (other instanceof Organisations) { + return other.accessToOperation("read").names().some( name => this.organisations.value(name)?.read ); + } else { + // return other.organisations.names().some( name => this.organisations.value(name) ); + } + } + + canRead (other) { + return this.canSee(other); + } + + canWrite (other) { + if (this.organisations.value("*")?.write) { + return true; + } else if (other instanceof User) { + return other.organisations.names().some( name => this.organisations.value(name) ); + } else if (other instanceof Organisations) { + return other.accessToOperation("write").names().some( name => this.organisations.value(name)?.write ); + } else { + // return other.organisations.names().some( name => this.organisations.value(name) ); + } + } + + /** Return `true` if we can edit `other` + * + * If we are edit wildcarded we can edit everyone + * + * If not, we must have `edit` access on at least one + * of other's organisations + */ + canEdit (other) { + if (this.organisations.value("*")?.edit) { + return true; + } else if (other instanceof User) { + return other.organisations.names().some( name => this.organisations.value(name)?.edit ); + } else if (other instanceof Organisations) { + return other.accessToOperation("edit").names().some( name => this.organisations.value(name)?.edit ); + } else if (other?.organisations) { + return this.canEdit(this.#clone(other)); + } + } + + canDo (operation, other) { + switch (operation) { + case "read": + return this.canRead(other); + case "write": + return this.canWrite(other); + case "edit": + return this.canEdit(other); + default: + return false; + } + } + + /** 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 +} diff --git a/lib/modules/@dougal/user/index.js b/lib/modules/@dougal/user/index.js new file mode 100644 index 0000000..1089edb --- /dev/null +++ b/lib/modules/@dougal/user/index.js @@ -0,0 +1,4 @@ + +module.exports = { + User: require('./User') +} diff --git a/lib/modules/@dougal/user/package-lock.json b/lib/modules/@dougal/user/package-lock.json new file mode 100644 index 0000000..c8773cd --- /dev/null +++ b/lib/modules/@dougal/user/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "@dougal/user", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dougal/user", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@dougal/organisations": "file:../organisations" + } + }, + "../organisations": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/@dougal/organisations": { + "resolved": "../organisations", + "link": true + } + } +} diff --git a/lib/modules/@dougal/user/package.json b/lib/modules/@dougal/user/package.json new file mode 100644 index 0000000..51b5215 --- /dev/null +++ b/lib/modules/@dougal/user/package.json @@ -0,0 +1,15 @@ +{ + "name": "@dougal/user", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@dougal/organisations": "file:../organisations" + } +}