mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 07:57:07 +00:00
365 lines
9.2 KiB
JavaScript
365 lines
9.2 KiB
JavaScript
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}(?<!-)\.)+[A-Za-z]{2,}$/;
|
|
return hostnameRx.test(str);
|
|
}
|
|
|
|
#setString (k, v) {
|
|
if (typeof v === "undefined") {
|
|
this.values[k] = v;
|
|
} else {
|
|
this.values[k] = String(v).trim();
|
|
}
|
|
this.emit("changed", k, v);
|
|
this.#updateTimestamp();
|
|
}
|
|
|
|
#updateTimestamp (v) {
|
|
if (typeof v === "undefined") {
|
|
this.#timestamp = (new Date()).valueOf();
|
|
} else {
|
|
this.#timestamp = (new Date(v)).valueOf();
|
|
}
|
|
this.emit("last_modified", this.#timestamp);
|
|
}
|
|
|
|
// Create a new instance of `other`, where `other` is
|
|
// an instance of User or of a derived class
|
|
#clone (other = this) {
|
|
const clone = new this.constructor();
|
|
Object.assign(clone.values, other.values);
|
|
clone.organisations = new Organisations(other.organisations);
|
|
return clone;
|
|
}
|
|
|
|
values = {}
|
|
|
|
#timestamp
|
|
|
|
constructor (data) {
|
|
super();
|
|
|
|
User.fields.forEach( f => 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
|
|
}
|