Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.mdns.eu/nextcloud/passwords-client.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Readme.md21
-rw-r--r--package-lock.json32
-rw-r--r--package.json26
-rw-r--r--src/Api/Api.js7
-rw-r--r--src/Api/BaseApi.js7
-rw-r--r--src/Authorization/Challenge/PWDv1Challenge.js102
-rw-r--r--src/Authorization/SessionAuthorization.js192
-rw-r--r--src/Authorization/Token/AbstractToken.js101
-rw-r--r--src/Authorization/Token/RequestToken.js28
-rw-r--r--src/Authorization/Token/UserToken.js23
-rw-r--r--src/Cache/Cache.js44
-rw-r--r--src/ClassLoader/BasicClassLoader.js83
-rw-r--r--src/ClassLoader/DefaultClassLoader.js181
-rw-r--r--src/ClassLoader/EnhancedClassLoader.js20
-rw-r--r--src/Classes/Encryption.js14
-rw-r--r--src/Classes/EnhancedApi.js4
-rw-r--r--src/Classes/SimpleApi.js5
-rw-r--r--src/Client/BasicPasswordsClient.js236
-rw-r--r--src/Client/PasswordsClient.js24
-rw-r--r--src/Collection/AbstractCollection.js172
-rw-r--r--src/Collection/CustomFieldCollection.js4
-rw-r--r--src/Collection/FolderCollection.js4
-rw-r--r--src/Collection/PasswordCollection.js4
-rw-r--r--src/Collection/SettingCollection.js49
-rw-r--r--src/Collection/TagCollection.js4
-rw-r--r--src/Configuration/DataField.json14
-rw-r--r--src/Configuration/EmailField.json14
-rw-r--r--src/Configuration/FileField.json14
-rw-r--r--src/Configuration/Folder.json85
-rw-r--r--src/Configuration/Password.json138
-rw-r--r--src/Configuration/SecretField.json14
-rw-r--r--src/Configuration/Server.json12
-rw-r--r--src/Configuration/Tag.json75
-rw-r--r--src/Configuration/TextField.json14
-rw-r--r--src/Configuration/UrlField.json14
-rw-r--r--src/Converter/AbstractConverter.js145
-rw-r--r--src/Converter/CustomFieldConverter.js88
-rw-r--r--src/Converter/FolderConverter.js11
-rw-r--r--src/Converter/PasswordConverter.js61
-rw-r--r--src/Converter/SettingConverter.js133
-rw-r--r--src/Converter/TagConverter.js11
-rw-r--r--src/Encryption/CSEv1Encryption.js174
-rw-r--r--src/Encryption/ExportV1Encryption.js36
-rw-r--r--src/Encryption/Keychain/CSEv1Keychain.js190
-rw-r--r--src/Encryption/NoEncryption.js48
-rw-r--r--src/Exception/ChallengeTypeNotSupported.js16
-rw-r--r--src/Exception/ConfigruationError.js9
-rw-r--r--src/Exception/Encryption/EncryptionNotEnabledError.js13
-rw-r--r--src/Exception/Encryption/InvalidEncryptedTextLength.js17
-rw-r--r--src/Exception/Encryption/InvalidObjectTypeError.js16
-rw-r--r--src/Exception/Encryption/MissingEncryptionKeyError.js16
-rw-r--r--src/Exception/Encryption/UnsupportedEncryptionTypeError.js38
-rw-r--r--src/Exception/Http/BadGatewayError.js18
-rw-r--r--src/Exception/Http/BadRequestError.js18
-rw-r--r--src/Exception/Http/ForbiddenError.js18
-rw-r--r--src/Exception/Http/GatewayTimeoutError.js18
-rw-r--r--src/Exception/Http/HttpError.js42
-rw-r--r--src/Exception/Http/InternalServerError.js18
-rw-r--r--src/Exception/Http/MethodNotAllowedError.js18
-rw-r--r--src/Exception/Http/NotFoundError.js18
-rw-r--r--src/Exception/Http/ServiceUnavailableError.js18
-rw-r--r--src/Exception/Http/TooManyRequestsError.js18
-rw-r--r--src/Exception/Http/UnauthorizedError.js18
-rw-r--r--src/Exception/InvalidScopeError.js16
-rw-r--r--src/Exception/NetworkError.js24
-rw-r--r--src/Exception/PassLink/InvalidLink.js13
-rw-r--r--src/Exception/PassLink/UnknownAction.js16
-rw-r--r--src/Exception/ResponseContentTypeError.js27
-rw-r--r--src/Exception/ResponseDecodingError.js40
-rw-r--r--src/Exception/TokenTypeNotSupported.js16
-rw-r--r--src/Exception/UnknownPropertyError.js34
-rw-r--r--src/Logger/Logger.js95
-rw-r--r--src/Model/AbstractModel.js109
-rw-r--r--src/Model/AbstractRevisionModel.js287
-rw-r--r--src/Model/CustomField/AbstractField.js95
-rw-r--r--src/Model/CustomField/DataField.js14
-rw-r--r--src/Model/CustomField/DefectField.js14
-rw-r--r--src/Model/CustomField/EmailField.js14
-rw-r--r--src/Model/CustomField/FileField.js14
-rw-r--r--src/Model/CustomField/SecretField.js14
-rw-r--r--src/Model/CustomField/TextField.js14
-rw-r--r--src/Model/CustomField/UrlField.js14
-rw-r--r--src/Model/Folder/EnhancedFolder.js70
-rw-r--r--src/Model/Folder/Folder.js119
-rw-r--r--src/Model/Password/EnhancedPassword.js130
-rw-r--r--src/Model/Password/Password.js232
-rw-r--r--src/Model/Server/Server.js80
-rw-r--r--src/Model/Session/Session.js84
-rw-r--r--src/Model/Setting/Setting.js179
-rw-r--r--src/Model/Tag/EnhancedTag.js38
-rw-r--r--src/Model/Tag/Tag.js45
-rw-r--r--src/Network/ApiRequest.js274
-rw-r--r--src/Network/ApiResponse.js4
-rw-r--r--src/Network/HttpRequest.js194
-rw-r--r--src/Network/HttpResponse.js60
-rw-r--r--src/PassLink/Action/Connect.js143
-rw-r--r--src/PassLink/Action/PassLinkAction.js27
-rw-r--r--src/PassLink/PassLink.js42
-rw-r--r--src/Repositories/AbstractRepository.js259
-rw-r--r--src/Repositories/FolderRepository.js20
-rw-r--r--src/Repositories/PasswordRepository.js20
-rw-r--r--src/Repositories/SettingRepository.js123
-rw-r--r--src/Repositories/TagRepository.js20
-rw-r--r--src/Services/HashService.js100
-rw-r--r--src/Services/ModelService.js283
-rw-r--r--src/Services/PasswordService.js34
-rw-r--r--src/State/BooleanState.js188
-rw-r--r--src/Utility/ObjectClone.js33
-rw-r--r--src/Utility/ObjectMerger.js28
-rw-r--r--src/main.js35
110 files changed, 6707 insertions, 52 deletions
diff --git a/Readme.md b/Readme.md
index 9394175..bcbf914 100644
--- a/Readme.md
+++ b/Readme.md
@@ -4,24 +4,11 @@ Cou can find the API documentation [here](https://git.mdns.eu/nextcloud/password
### Using the client
You can use the enhanced version of the client in your project like this:
```javascript
-import EnhancedApi from 'passwords-client';
+import PasswordsClient from 'passwords-client';
-let api = new EnhancedApi();
-api.initialize({baseUrl:'https://cloud.example.com', user:'user', password:'password'});
-```
-
-#### Using the simple api
-There is a "slim" version of the api.
-This version will just communicate with the api but does no processing or encryption of the objects.
-
-```javascript
-import EventEmitter from 'eventemitter3';
-import {SimpleApi} from 'passwords-client';
-
-let events = new EventEmitter(),
- api = new SimpleApi();
-
-api.initialize({apiUrl:'https://cloud.example.com/index.php/apps/passwords/', user:'user', password:'password', events});
+let client = new PasswordsClient({baseUrl:'https://cloud.example.com/', user:'user', token:'12345-12345-12345-12345-12345'});
+let passwordsRepository = client.getPasswordRepository();
+let passwordCollection = await passwordsRepository.findAll();
```
diff --git a/package-lock.json b/package-lock.json
index 2c93714..334291a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,13 @@
{
"name": "passwords-client",
- "version": "0.0.11",
+ "version": "0.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"eventemitter3": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
- "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"libsodium": {
"version": "0.7.8",
@@ -22,10 +22,15 @@
"libsodium": "0.7.8"
}
},
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
"querystringify": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz",
+ "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
},
"requires-port": {
"version": "1.0.0",
@@ -40,6 +45,19 @@
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
+ },
+ "uuid": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
+ "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA=="
+ },
+ "uuidv4": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/uuidv4/-/uuidv4-4.0.0.tgz",
+ "integrity": "sha512-mG90kcW04v6frNmnLsnd7xqiKubIYgaQHxaHoBplAJpR95hgqkTDq8wpZQU5cN5w9gtKphqdsflHoEnXr2+ing==",
+ "requires": {
+ "uuid": "3.3.2"
+ }
}
}
}
diff --git a/package.json b/package.json
index 8020b67..ce1ea4e 100644
--- a/package.json
+++ b/package.json
@@ -1,23 +1,25 @@
{
- "name": "passwords-client",
- "version": "0.0.11",
- "description": "JS client library for the Nextcloud Passwords app",
- "main": "src/main.js",
- "author": "Marius Wieschollek",
- "license": "ISC",
- "keywords": [
+ "name" : "passwords-client",
+ "version" : "0.1.0",
+ "description" : "JS client library for the Nextcloud Passwords app",
+ "main" : "src/main.js",
+ "author" : "Marius Wieschollek",
+ "license" : "ISC",
+ "keywords" : [
"crypto",
"passwords",
"api",
"nextcloud"
],
- "repository": {
+ "repository" : {
"type": "git",
- "url": "https://git.mdns.eu/nextcloud/passwords-client.git"
+ "url" : "https://git.mdns.eu/nextcloud/passwords-client.git"
},
"dependencies": {
- "url-parse": "^1.4.7",
- "eventemitter3": "^3.1.2",
- "libsodium-wrappers": "^0.7.8"
+ "eventemitter3" : "^4.0.7",
+ "libsodium-wrappers": "^0.7.8",
+ "pako" : "^1.0.11",
+ "url-parse" : "^1.4.7",
+ "uuidv4" : "^4.0.0"
}
}
diff --git a/src/Api/Api.js b/src/Api/Api.js
new file mode 100644
index 0000000..8ccd7a3
--- /dev/null
+++ b/src/Api/Api.js
@@ -0,0 +1,7 @@
+import PasswordsClient from "../Client/PasswordsClient";
+
+/**
+ * @deprecated
+ */
+export default class Api extends PasswordsClient {
+} \ No newline at end of file
diff --git a/src/Api/BaseApi.js b/src/Api/BaseApi.js
new file mode 100644
index 0000000..7d9c3df
--- /dev/null
+++ b/src/Api/BaseApi.js
@@ -0,0 +1,7 @@
+import BasicPasswordsClient from "../Client/BasicPasswordsClient";
+
+/**
+ * @deprecated
+ */
+export default class BaseApi extends BasicPasswordsClient {
+} \ No newline at end of file
diff --git a/src/Authorization/Challenge/PWDv1Challenge.js b/src/Authorization/Challenge/PWDv1Challenge.js
new file mode 100644
index 0000000..13a3559
--- /dev/null
+++ b/src/Authorization/Challenge/PWDv1Challenge.js
@@ -0,0 +1,102 @@
+import sodium from 'libsodium-wrappers';
+
+export default class PWDv1Challenge {
+
+ constructor(data) {
+ this._salts = null;
+ if(data.hasOwnProperty('salts')) {
+ this._salts = data.salts;
+ }
+ this._password = null;
+ }
+
+ /**
+ *
+ * @returns {null}
+ */
+ getPassword() {
+ return this._password;
+ }
+
+ /**
+ *
+ * @param value
+ * @returns {PWDv1Challenge}
+ */
+ setPassword(value) {
+ this._password = value;
+
+ return this;
+ }
+
+ /**
+ * Generate a challenge solution with the user provided password
+ * and the server provided salts
+ *
+ * @returns {string}
+ */
+ solve() {
+ if(this._password.length < 12) throw new Error('Password is too short');
+ if(this._password.length > 128) throw new Error('Password is too long');
+ let salts = this._salts;
+
+ let passwordSalt = sodium.from_hex(salts[0]),
+ genericHashKey = sodium.from_hex(salts[1]),
+ passwordHashSalt = sodium.from_hex(salts[2]),
+ genericHash = sodium.crypto_generichash(
+ sodium.crypto_generichash_BYTES_MAX,
+ new Uint8Array([...sodium.from_string(this._password), ...passwordSalt]),
+ genericHashKey
+ );
+
+ let passwordHash = sodium.crypto_pwhash(
+ sodium.crypto_box_SEEDBYTES,
+ genericHash,
+ passwordHashSalt,
+ sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_ALG_DEFAULT
+ );
+
+
+ return sodium.to_hex(passwordHash);
+ }
+
+ /**
+ * Create the salts and the secret for the server
+ * using the user provided password
+ *
+ * @returns {{salts: *[], secret: *}}
+ */
+ create() {
+ if(this._password.length < 12) throw new Error('Password is too short');
+ if(this._password.length > 128) throw new Error('Password is too long');
+
+ let passwordSalt = sodium.randombytes_buf(256),
+ genericHashKey = sodium.randombytes_buf(sodium.crypto_generichash_KEYBYTES_MAX),
+ genericHash = sodium.crypto_generichash(
+ sodium.crypto_generichash_BYTES_MAX,
+ new Uint8Array([...sodium.from_string(this._password), ...passwordSalt]),
+ genericHashKey
+ );
+
+ let passwordHashSalt = sodium.sodium(sodium.crypto_pwhash_SALTBYTES),
+ passwordHash = sodium.crypto_pwhash(
+ sodium.crypto_box_SEEDBYTES,
+ genericHash,
+ passwordHashSalt,
+ sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_ALG_DEFAULT
+ );
+
+ return {
+ salts : [
+ sodium.to_hex(passwordSalt),
+ sodium.to_hex(genericHashKey),
+ sodium.to_hex(passwordHashSalt)
+ ],
+ secret: sodium.to_hex(passwordHash)
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Authorization/SessionAuthorization.js b/src/Authorization/SessionAuthorization.js
new file mode 100644
index 0000000..c1abad8
--- /dev/null
+++ b/src/Authorization/SessionAuthorization.js
@@ -0,0 +1,192 @@
+import AbstractToken from './Token/AbstractToken';
+
+export default class SessionAuthorization {
+
+ /**
+ *
+ * @param {BasicPasswordsClient} client
+ */
+ constructor(client) {
+ this._client = client;
+ this._challenge = null;
+ this._activeToken = null;
+ this._tokens = [];
+ this._loaded = false;
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ isLoaded() {
+ return this._loaded;
+ }
+
+ /**
+ *
+ * @return {Promise<void>}
+ */
+ async load() {
+ if(this._loaded) return;
+ this._loaded = true;
+ let response = await this._client.getRequest()
+ .setPath('1.0/session/request')
+ .send();
+
+ let requirements = response.getData();
+ if(requirements.hasOwnProperty('challenge')) {
+ this._createChallenge(requirements.challenge);
+ }
+ if(requirements.hasOwnProperty('token')) {
+ this._createTokens(requirements.token);
+ }
+ }
+
+ /**
+ *
+ * @return {Boolean}
+ * @deprecated
+ */
+ hasChallenge() {
+ return this.requiresChallenge();
+ }
+
+ /**
+ *
+ * @return {Boolean}
+ */
+ requiresChallenge() {
+ return this._challenge !== null;
+ }
+
+ /**
+ *
+ * @returns {PWDv1Challenge}
+ */
+ getChallenge() {
+ return this._challenge;
+ }
+
+ /**
+ *
+ * @return {Boolean}
+ */
+ requiresToken() {
+ return this._tokens.length !== 0;
+ }
+
+ /**
+ *
+ * @return {AbstractToken[]}
+ */
+ getTokens() {
+ return this._tokens;
+ }
+
+ /**
+ *
+ * @return {(AbstractToken|null)}
+ */
+ getActiveToken() {
+ return this._activeToken;
+ }
+
+ /**
+ *
+ * @param {(AbstractToken|String|null)} tokenId
+ * @return {SessionAuthorization}
+ */
+ setActiveToken(tokenId) {
+ if(tokenId instanceof AbstractToken) {
+ tokenId = tokenId.getId();
+ }
+
+ if(tokenId === null) {
+ this._activeToken = null;
+ }
+
+ for(let token of this._tokens) {
+ if(token.getId() === tokenId) {
+ this._activeToken = token;
+ }
+ }
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} [password]
+ * @param {(String|AbstractToken)} [token]
+ * @return {Promise<void>}
+ */
+ async authorize(password, token) {
+ let data = {};
+ if(this.requiresChallenge()) {
+ if(password) {
+ data.challenge = this._challenge
+ .setPassword(password)
+ .solve();
+ } else {
+ data.challenge = this._challenge.solve();
+ }
+ }
+
+ if(this.requiresToken()) {
+ if(token) this.setActiveToken(token);
+ token = this.getActiveToken();
+
+ data.token = {};
+ data.token[token.getId()] = token.getToken();
+ }
+
+ let request = await this._client.getRequest()
+ .setPath('1.0/session/open')
+ .setData(data);
+
+ let response = await request.send();
+ if(response.getData().success) {
+ if(this.requiresChallenge()) {
+ let keychain = this._client.getClass('keychain.csev1', response.getData().keys.CSEv1r1, this._challenge.getPassword());
+ this._client.getCseV1Encryption().setKeychain(keychain);
+ }
+ request.getSession().setAuthorized(true);
+ }
+ }
+
+ /**
+ *
+ * @param {Object} challenge
+ * @private
+ */
+ _createChallenge(challenge) {
+ if(challenge.type === 'PWDv1r1') {
+ this._challenge = this._client.getClass('challenge.pwdv1', challenge);
+ } else {
+ throw new this._client.getClass('exception.challenge');
+ }
+ }
+
+ /**
+ *
+ * @param {Object[]} tokens
+ * @private
+ */
+ _createTokens(tokens) {
+ this._tokens = [];
+
+ for(let token of tokens) {
+ if(token.type === 'user-token') {
+ let model = this._client.getClass('token.user', this._client, token.id, token.label, token.description, token.request);
+ this._tokens.push(model);
+ }
+ if(token.type === 'request-token') {
+ let model = this._client.getClass('token.request', this._client, token.id, token.label, token.description, token.request);
+ this._tokens.push(model);
+ }
+ }
+
+ if(this._tokens.length === 0 && tokens.length !== 0) {
+ throw new this._client.getClass('exception.token');
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Authorization/Token/AbstractToken.js b/src/Authorization/Token/AbstractToken.js
new file mode 100644
index 0000000..cb7e5e6
--- /dev/null
+++ b/src/Authorization/Token/AbstractToken.js
@@ -0,0 +1,101 @@
+export default class AbstractToken {
+
+ /**
+ *
+ * @param {BasicPasswordsClient} api
+ * @param {String} id
+ * @param {String} label
+ * @param {String} description
+ * @param {Boolean} request
+ */
+ constructor(api, id, label, description, request) {
+ this._id = id;
+ this._api = api;
+ this._label = label;
+ this._description = description;
+ this._request = request;
+ this._token = null;
+ }
+
+ /**
+ *
+ * @return {String}
+ */
+ getType() {
+ return 'abstract-token';
+ }
+
+ /**
+ *
+ * @return {String}
+ */
+ getId() {
+ return this._id;
+ }
+
+ /**
+ *
+ * @return {String}
+ */
+ getLabel() {
+ return this._label;
+ }
+
+ /**
+ *
+ * @return {String}
+ */
+ getDescription() {
+ return this._description;
+ }
+
+ /**
+ *
+ * @return {Boolean}
+ */
+ requiresRequest() {
+ return this._request;
+ }
+
+ /**
+ *
+ * @return {(String|null)}
+ */
+ getToken() {
+ return this._token;
+ }
+
+ /**
+ *
+ * @return {Promise<boolean>}
+ */
+ async sendRequest() {
+ if(!this.requiresRequest()) return true;
+
+ try {
+ await this._api
+ .getRequest()
+ .setPath(`1.0/token/${this._id}/request`)
+ .send();
+ return true;
+ } catch(e) {
+ console.error(e);
+ }
+
+ return false;
+ }
+
+ /**
+ *
+ * @return {Object}
+ */
+ toJSON() {
+ return {
+ type : this.getType,
+ id : this._id,
+ label : this._label,
+ description: this._description,
+ request : this._request
+ };
+ }
+} \ No newline at end of file
diff --git a/src/Authorization/Token/RequestToken.js b/src/Authorization/Token/RequestToken.js
new file mode 100644
index 0000000..b7e1e49
--- /dev/null
+++ b/src/Authorization/Token/RequestToken.js
@@ -0,0 +1,28 @@
+import AbstractToken from './AbstractToken';
+
+export default class RequestToken extends AbstractToken {
+
+ /**
+ *
+ * @return {String}
+ */
+ getType() {
+ return 'request-token';
+ }
+
+ async sendRequest() {
+ if(!this.requiresRequest()) return true;
+
+ try {
+ this._token = await this._api
+ .getRequest()
+ .setPath(`1.0/token/${this._id}/request`)
+ .send();
+ return true;
+ } catch(e) {
+ console.error(e);
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/src/Authorization/Token/UserToken.js b/src/Authorization/Token/UserToken.js
new file mode 100644
index 0000000..f19c7e3
--- /dev/null
+++ b/src/Authorization/Token/UserToken.js
@@ -0,0 +1,23 @@
+import AbstractToken from './AbstractToken';
+
+export default class UserToken extends AbstractToken {
+
+ /**
+ *
+ * @return {String}
+ */
+ getType() {
+ return 'user-token';
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @return {UserToken}
+ */
+ setToken(value) {
+ this._token = value;
+
+ return this;
+ }
+} \ No newline at end of file
diff --git a/src/Cache/Cache.js b/src/Cache/Cache.js
new file mode 100644
index 0000000..43dd6b4
--- /dev/null
+++ b/src/Cache/Cache.js
@@ -0,0 +1,44 @@
+export default class Cache {
+
+ constructor() {
+ this._data = {};
+ }
+
+ has(key) {
+ return this._data.hasOwnProperty(key);
+ }
+
+ get(key) {
+ if(this.has(key)) {
+ return this._data[key].value;
+ }
+
+ return null;
+ }
+
+ set(key, value, type = null) {
+ this._data[key] = {value, type};
+ }
+
+ remove(key) {
+ delete this._data[key];
+ }
+
+ clear() {
+ this._data = {};
+ }
+
+ getByType(type) {
+ let results = [];
+ for(let key in this._data) {
+ if(!this._data.hasOwnProperty(key)) continue;
+ let element = this._data[key];
+
+ if(element.type === type) {
+ results.push(element.value);
+ }
+ }
+
+ return results;
+ }
+} \ No newline at end of file
diff --git a/src/ClassLoader/BasicClassLoader.js b/src/ClassLoader/BasicClassLoader.js
new file mode 100644
index 0000000..d4ef1e8
--- /dev/null
+++ b/src/ClassLoader/BasicClassLoader.js
@@ -0,0 +1,83 @@
+import ObjectMerger from "../Utility/ObjectMerger";
+
+export default class BasicClassLoader {
+
+ constructor(classes = {}) {
+ this._classes = Object.assign(this._getDefaultClasses(), classes);
+ this._instances = {};
+ }
+
+
+ /**
+ *
+ * @param {String} name
+ * @param {*} properties
+ * @return {Object}
+ * @api
+ */
+ getInstance(name, ...properties) {
+ if(!this._instances.hasOwnProperty(name) || !this._instances[name]) {
+ this._instances[name] = this.getClass(name, ...properties);
+ }
+
+ return this._instances[name];
+ }
+
+ /**
+ *
+ * @param {String} name
+ * @param {Object} object
+ * @return {BasicClassLoader}
+ * @api
+ */
+ setInstance(name, object) {
+ this._instances[name] = object;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} name
+ * @param {*} properties
+ * @return {Object}
+ */
+ getClass(name, ...properties) {
+ if(!this._classes.hasOwnProperty(name)) {
+ throw new Error(`The class ${name} does not exist`);
+ }
+
+ let creator = this._classes[name];
+ if(creator instanceof Function) {
+ if(!creator.prototype || creator.hasOwnProperty('arguments') && creator.hasOwnProperty('caller')) {
+ return creator(...properties);
+ }
+
+ return new creator(...properties);
+ } else {
+ return creator;
+ }
+ }
+
+ /**
+ *
+ * @param {String} name
+ * @param {Object} constructor
+ * @return {BasicClassLoader}
+ * @api
+ */
+ registerClass(name, constructor) {
+ this._classes[name] = constructor;
+
+ return this;
+ }
+
+ /**
+ *
+ * @return {Object}
+ * @protected
+ */
+ _getDefaultClasses() {
+ return {};
+ }
+} \ No newline at end of file
diff --git a/src/ClassLoader/DefaultClassLoader.js b/src/ClassLoader/DefaultClassLoader.js
new file mode 100644
index 0000000..d749664
--- /dev/null
+++ b/src/ClassLoader/DefaultClassLoader.js
@@ -0,0 +1,181 @@
+import PasswordRepository from "../Repositories/PasswordRepository";
+import FolderRepository from "../Repositories/FolderRepository";
+import TagRepository from "../Repositories/TagRepository";
+import SettingRepository from "../Repositories/SettingRepository";
+import PasswordCollection from "../Collection/PasswordCollection";
+import FolderCollection from "../Collection/FolderCollection";
+import CustomFieldCollection from "../Collection/CustomFieldCollection";
+import TagCollection from "../Collection/TagCollection";
+import SettingCollection from "../Collection/SettingCollection";
+import PasswordConverter from "../Converter/PasswordConverter";
+import FolderConverter from "../Converter/FolderConverter";
+import CustomFieldConverter from "../Converter/CustomFieldConverter";
+import TagConverter from "../Converter/TagConverter";
+import SettingConverter from "../Converter/SettingConverter";
+import Password from "../Model/Password/Password";
+import Folder from "../Model/Folder/Folder";
+import Tag from "../Model/Tag/Tag";
+import Server from "../Model/Server/Server";
+import Session from "../Model/Session/Session";
+import DataField from "../Model/CustomField/DataField";
+import EmailField from "../Model/CustomField/EmailField";
+import FileField from "../Model/CustomField/FileField";
+import SecretField from "../Model/CustomField/SecretField";
+import TextField from "../Model/CustomField/TextField";
+import UrlField from "../Model/CustomField/UrlField";
+import Setting from "../Model/Setting/Setting";
+import ApiRequest from "../Network/ApiRequest";
+import ApiResponse from "../Network/ApiResponse";
+import SessionAuthorization from "../Authorization/SessionAuthorization";
+import PWDv1Challenge from "../Authorization/Challenge/PWDv1Challenge";
+import UserToken from "../Authorization/Token/UserToken";
+import RequestToken from "../Authorization/Token/RequestToken";
+import NoEncryption from "../Encryption/NoEncryption";
+import CSEv1Encryption from "../Encryption/CSEv1Encryption";
+import ExportV1Encryption from "../Encryption/ExportV1Encryption";
+import CSEv1Keychain from "../Encryption/Keychain/CSEv1Keychain";
+import Cache from "../Cache/Cache";
+import BooleanState from "../State/BooleanState";
+import EventEmitter from "eventemitter3";
+import ResponseContentTypeError from "../Exception/ResponseContentTypeError";
+import ResponseDecodingError from "../Exception/ResponseDecodingError";
+import UnknownPropertyError from "../Exception/UnknownPropertyError";
+import TokenTypeNotSupported from "../Exception/TokenTypeNotSupported";
+import NetworkError from "../Exception/NetworkError";
+import HttpError from "../Exception/Http/HttpError";
+import BadRequestError from "../Exception/Http/BadRequestError";
+import UnauthorizedError from "../Exception/Http/UnauthorizedError";
+import ForbiddenError from "../Exception/Http/ForbiddenError";
+import NotFoundError from "../Exception/Http/NotFoundError";
+import MethodNotAllowedError from "../Exception/Http/MethodNotAllowedError";
+import TooManyRequestsError from "../Exception/Http/TooManyRequestsError";
+import InternalServerError from "../Exception/Http/InternalServerError";
+import BadGatewayError from "../Exception/Http/BadGatewayError";
+import ServiceUnavailableError from "../Exception/Http/ServiceUnavailableError";
+import GatewayTimeoutError from "../Exception/Http/GatewayTimeoutError";
+import BasicClassLoader from "./BasicClassLoader";
+import ModelService from "../Services/ModelService";
+import PasswordService from "../Services/PasswordService";
+import UnsupportedEncryptionTypeError from "../Exception/Encryption/UnsupportedEncryptionTypeError";
+import InvalidObjectTypeError from "../Exception/Encryption/InvalidObjectTypeError";
+import EncryptionNotEnabledError from "../Exception/Encryption/EncryptionNotEnabledError";
+import ChallengeTypeNotSupported from "../Exception/ChallengeTypeNotSupported";
+import ConfigurationError from "../Exception/ConfigruationError";
+import MissingEncryptionKeyError from "../Exception/Encryption/MissingEncryptionKeyError";
+import InvalidEncryptedTextLength from "../Exception/Encryption/InvalidEncryptedTextLength";
+import HashService from "../Services/HashService";
+import Logger from "../Logger/Logger";
+import DefectField from "../Model/CustomField/DefectField";
+
+export default class DefaultClassLoader extends BasicClassLoader {
+
+ /**
+ *
+ * @return {Object}
+ * @protected
+ */
+ _getDefaultClasses() {
+ return {
+ 'repository.password': () => { return new PasswordRepository(this.getInstance('client')); },
+ 'repository.folder' : () => { return new FolderRepository(this.getInstance('client')); },
+ 'repository.tag' : () => { return new TagRepository(this.getInstance('client')); },
+ 'repository.setting' : () => { return new SettingRepository(this.getInstance('client')); },
+
+ 'collection.password': (...e) => { return new PasswordCollection(this.getInstance('converter.password'), ...e); },
+ 'collection.folder' : (...e) => { return new FolderCollection(this.getInstance('converter.folder'), ...e); },
+ 'collection.field' : (...e) => { return new CustomFieldCollection(this.getInstance('converter.field'), ...e); },
+ 'collection.tag' : (...e) => { return new TagCollection(this.getInstance('converter.tag'), ...e); },
+ 'collection.setting' : (...e) => { return new SettingCollection(this.getInstance('converter.setting'), ...e); },
+
+ 'converter.password': () => { return new PasswordConverter(this.getInstance('client')); },
+ 'converter.folder' : () => { return new FolderConverter(this.getInstance('client')); },
+ 'converter.field' : () => { return new CustomFieldConverter(this.getInstance('client')); },
+ 'converter.tag' : () => { return new TagConverter(this.getInstance('client')); },
+ 'converter.setting' : () => { return new SettingConverter(this.getInstance('client')); },
+
+ 'model.password' : Password,
+ 'model.folder' : Folder,
+ 'model.tag' : Tag,
+ 'model.server' : Server,
+ 'model.session' : Session,
+ 'model.dataField' : DataField,
+ 'model.emailField' : EmailField,
+ 'model.fileField' : FileField,
+ 'model.secretField': SecretField,
+ 'model.textField' : TextField,
+ 'model.urlField' : UrlField,
+ 'model.defectField': DefectField,
+ 'model.setting' : Setting,
+
+ 'network.request' : ApiRequest,
+ 'network.response': ApiResponse,
+
+ 'authorization.session': () => { return new SessionAuthorization(this.getInstance('client')); },
+
+ 'challenge.pwdv1': PWDv1Challenge,
+
+ 'token.user' : UserToken,
+ 'token.request': RequestToken,
+
+ 'encryption.none' : () => { return new NoEncryption(this.getInstance('classes')); },
+ 'encryption.csev1': () => { return new CSEv1Encryption(this.getInstance('classes')); },
+ 'encryption.expv1': () => { return new ExportV1Encryption(this.getInstance('classes')); },
+
+ 'keychain.csev1': (k, p) => { return new CSEv1Keychain(this.getInstance('classes'), k, p); },
+
+ 'service.hash' : () => { return new HashService(this.getInstance('classes')); },
+ 'service.model' : () => { return new ModelService(this.getInstance('classes')); },
+ 'service.password': () => { return new PasswordService(this.getInstance('client')); },
+
+ 'logger': Logger,
+
+ 'cache.cache': Cache,
+
+ 'state.boolean': BooleanState,
+
+ 'event.event': EventEmitter,
+
+ 'exception.response.contenttype' : ResponseContentTypeError,
+ 'exception.response.decoding' : ResponseDecodingError,
+ 'exception.model.property' : UnknownPropertyError,
+ 'exception.auth.challenge' : ChallengeTypeNotSupported,
+ 'exception.auth.token' : TokenTypeNotSupported,
+ 'exception.network' : NetworkError,
+ 'exception.http' : HttpError,
+ 'exception.http.400' : BadRequestError,
+ 'exception.http.401' : UnauthorizedError,
+ 'exception.http.403' : ForbiddenError,
+ 'exception.http.404' : NotFoundError,
+ 'exception.http.405' : MethodNotAllowedError,
+ 'exception.http.429' : TooManyRequestsError,
+ 'exception.http.500' : InternalServerError,
+ 'exception.http.502' : BadGatewayError,
+ 'exception.http.503' : ServiceUnavailableError,
+ 'exception.http.504' : GatewayTimeoutError,
+ 'exception.encryption.unsupported': UnsupportedEncryptionTypeError,
+ 'exception.encryption.object' : InvalidObjectTypeError,
+ 'exception.encryption.enabled' : EncryptionNotEnabledError,
+ 'exception.encryption.key.missing': MissingEncryptionKeyError,
+ 'exception.encryption.text.length': InvalidEncryptedTextLength,
+ 'exception.configuration' : ConfigurationError,
+
+
+ // Old deprecated errors
+ 'exception.contenttype': ResponseContentTypeError,
+ 'exception.decoding' : ResponseDecodingError,
+ 'exception.property' : UnknownPropertyError,
+ 'exception.challenge' : TokenTypeNotSupported,
+ 'exception.token' : TokenTypeNotSupported,
+ 'exception.400' : BadRequestError,
+ 'exception.401' : UnauthorizedError,
+ 'exception.403' : ForbiddenError,
+ 'exception.404' : NotFoundError,
+ 'exception.405' : MethodNotAllowedError,
+ 'exception.429' : TooManyRequestsError,
+ 'exception.500' : InternalServerError,
+ 'exception.502' : BadGatewayError,
+ 'exception.503' : ServiceUnavailableError,
+ 'exception.504' : GatewayTimeoutError
+ };
+ };
+} \ No newline at end of file
diff --git a/src/ClassLoader/EnhancedClassLoader.js b/src/ClassLoader/EnhancedClassLoader.js
new file mode 100644
index 0000000..cc4c667
--- /dev/null
+++ b/src/ClassLoader/EnhancedClassLoader.js
@@ -0,0 +1,20 @@
+import DefaultClassLoader from "./DefaultClassLoader";
+import EnhancedPassword from "../Model/Password/EnhancedPassword";
+import EnhancedFolder from "../Model/Folder/EnhancedFolder";
+import EnhancedTag from "../Model/Tag/EnhancedTag";
+
+export default class EnhancedClassLoader extends DefaultClassLoader {
+ /**
+ *
+ * @return {Object}
+ * @protected
+ */
+ _getDefaultClasses() {
+ let classes = super._getDefaultClasses();
+ classes['model.password'] = (d) => { return new EnhancedPassword(d, this.getInstance('client')); };
+ classes['model.folder'] = (d) => { return new EnhancedFolder(d, this.getInstance('client')); };
+ classes['model.tag'] = (d) => { return new EnhancedTag(d, this.getInstance('client')); };
+
+ return classes;
+ }
+} \ No newline at end of file
diff --git a/src/Classes/Encryption.js b/src/Classes/Encryption.js
index 1454e6a..3e310c7 100644
--- a/src/Classes/Encryption.js
+++ b/src/Classes/Encryption.js
@@ -17,7 +17,7 @@ export default class Encryption {
/**
*
- * @returns {boolean}
+ * @returns {Boolean}
*/
get enabled() {
return this._enabled;
@@ -25,7 +25,7 @@ export default class Encryption {
/**
*
- * @returns {boolean}
+ * @returns {Boolean}
*/
get keys() {
return Object.keys(this._keys);
@@ -43,7 +43,7 @@ export default class Encryption {
/**
* Returns true if the user has base64 encoded properties
*
- * @return {boolean}
+ * @return {Boolean}
*/
hasLegacyEncoding() {
return this._legacyEncoding;
@@ -144,7 +144,7 @@ export default class Encryption {
*
* @param message
* @param key
- * @returns {string}
+ * @returns {String}
*/
encryptString(message, key) {
return sodium.to_hex(this.encrypt(message, key));
@@ -168,7 +168,7 @@ export default class Encryption {
*
* @param encodedString
* @param key
- * @returns {string}
+ * @returns {String}
*/
decryptString(encodedString, key) {
try {
@@ -206,7 +206,7 @@ export default class Encryption {
*
* @param salts
* @param password
- * @returns {string}
+ * @returns {String}
*/
solveChallenge(password, salts) {
if(password.length < 12) throw new Error('Password is too short');
@@ -351,7 +351,7 @@ export default class Encryption {
/**
* Create a uuidv4
*
- * @returns {string}
+ * @returns {String}
*/
getUuid() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
diff --git a/src/Classes/EnhancedApi.js b/src/Classes/EnhancedApi.js
index 03c5a0d..605df4b 100644
--- a/src/Classes/EnhancedApi.js
+++ b/src/Classes/EnhancedApi.js
@@ -8,7 +8,7 @@ export default class EnhancedApi extends SimpleApi {
/**
* Is the user session authorized to make encrypted requests
*
- * @returns {boolean}
+ * @returns {Boolean}
*/
get isAuthorized() {
return this._isAuthorized === true;
@@ -17,7 +17,7 @@ export default class EnhancedApi extends SimpleApi {
/**
* Is the encryption active and able to encrypt/decrypt
*
- * @returns {boolean}
+ * @returns {Boolean}
*/
get hasEncryption() {
return this.config.encryption.enabled;
diff --git a/src/Classes/SimpleApi.js b/src/Classes/SimpleApi.js
index 0a577c2..5ca8430 100644
--- a/src/Classes/SimpleApi.js
+++ b/src/Classes/SimpleApi.js
@@ -107,7 +107,8 @@ export default class SimpleApi {
if(config.user !== null && config.password !== null) {
this._headers.Authorization = `Basic ${btoa(`${config.user}:${config.password}`)}`;
} else {
- throw new Error('Api username or password missing');
+ // @TODO Use custom error here
+ throw new Error('API username or password missing');
}
this._enabled = true;
@@ -787,7 +788,7 @@ export default class SimpleApi {
/**
*
* @param path
- * @returns {string}
+ * @returns {String}
* @private
*/
_getRequestUrl(path) {
diff --git a/src/Client/BasicPasswordsClient.js b/src/Client/BasicPasswordsClient.js
new file mode 100644
index 0000000..a0411e3
--- /dev/null
+++ b/src/Client/BasicPasswordsClient.js
@@ -0,0 +1,236 @@
+import ConfigurationError from "../Exception/ConfigruationError";
+
+export default class BasicPasswordsClient {
+
+ /**
+ *
+ * @param server
+ * @param config
+ * @param classes
+ */
+ constructor(server, config, classes) {
+ this._setConfig(config);
+ this._classes = classes;
+ this._classes.setInstance('model.server', server);
+ this._classes.setInstance('api', this);
+ this._classes.setInstance('client', this);
+ this._classes.setInstance('classes', this._classes);
+
+ this._server = server;
+ this._events = this._classes.getInstance('event.event');
+ this.renewSession();
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @param {Function} listener
+ */
+ on(event, listener) {
+ this._events.on(event, listener);
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @param {Function} listener
+ */
+ once(event, listener) {
+ this._events.once(event, listener);
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @param {Function} listener
+ */
+ off(event, listener) {
+ this._events.off(event, listener);
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @param {Object} data
+ */
+ emit(event, data) {
+ this._events.emit(event, data);
+ }
+
+ /**
+ *
+ * @return {Server}
+ */
+ getServer() {
+ return this._server;
+ }
+
+ /**
+ * @returns {Logger}
+ */
+ getLogger() {
+ return this._classes.getInstance('logger');
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ isAuthorized() {
+ return this
+ .getSession()
+ .getAuthorized();
+ }
+
+ /**
+ *
+ * @return {ApiRequest}
+ */
+ getRequest() {
+ /** @type {ApiRequest} **/
+ let request = this._classes.getClass('network.request', this, this._server.getApiUrl(), this.getSession());
+ if(this._config.userAgent !== null) {
+ request.setUserAgent(this._config.userAgent);
+ }
+
+ return request;
+ }
+
+ /**
+ * @returns {Session}
+ */
+ getSession() {
+ return this
+ ._session
+ .setUser(this._server.getUser())
+ .setToken(this._server.getToken());
+ }
+
+ /**
+ * Replaces the session with a blank one
+ *
+ * @returns {Session}
+ */
+ renewSession() {
+ this._session = this._classes.getClass('model.session', this._server.getUser(), this._server.getToken());
+ this._classes.setInstance('session', this._session);
+ this._classes.setInstance('model.session', this._session);
+ this._classes.setInstance('authorization.session', this._classes.getClass('authorization.session'));
+ return this._session;
+ }
+
+ /**
+ *
+ * @returns {SessionAuthorization}
+ */
+ getSessionAuthorization() {
+ return this._classes.getInstance('authorization.session');
+ }
+
+ /**
+ *
+ * @returns {PasswordRepository}
+ */
+ getPasswordRepository() {
+ return this._classes.getInstance('repository.password');
+ }
+
+ /**
+ *
+ * @returns {FolderRepository}
+ */
+ getFolderRepository() {
+ return this._classes.getInstance('repository.folder');
+ }
+
+ /**
+ *
+ * @returns {TagRepository}
+ */
+ getTagRepository() {
+ return this._classes.getInstance('repository.tag');
+ }
+
+ /**
+ *
+ * @returns {CSEv1Encryption}
+ */
+ getCseV1Encryption() {
+ return this._classes.getInstance('encryption.csev1');
+ }
+
+ /**
+ *
+ * @returns {CSEv1Encryption}
+ */
+ getDefaultEncryption() {
+ let mode = 'auto';
+ if(this._config.hasOwnProperty('defaultEncryption')) {
+ mode = this._config.defaultEncryption;
+ }
+
+ if(mode === 'none') {
+ return this._classes.getInstance('encryption.none');
+ }
+ if(mode === 'csev1') {
+ return this._classes.getInstance('encryption.csev1');
+ }
+
+ let csev1 = this._classes.getInstance('encryption.csev1');
+ if(csev1.enabled()) return csev1;
+
+ return this._classes.getInstance('encryption.none');
+ }
+
+ /**
+ *
+ * @param parameters
+ * @return {*}
+ */
+ getInstance(...parameters) {
+ return this._classes.getInstance(...parameters);
+ }
+
+ /**
+ *
+ * @param parameters
+ * @return {*}
+ */
+ setInstance(...parameters) {
+ return this._classes.setInstance(...parameters);
+ }
+
+ /**
+ *
+ * @param parameters
+ * @return {*}
+ */
+ getClass(...parameters) {
+ return this._classes.getClass(...parameters);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ toJSON() {
+ return this.getServer().getProperties();
+ }
+
+ /**
+ *
+ * @param config
+ * @private
+ */
+ _setConfig(config) {
+ if(!config.hasOwnProperty('userAgent')) {
+ config.userAgent = null;
+ }
+
+ if(config.hasOwnProperty('defaultEncryption') && ['auto', 'none', 'csev1'].indexOf(config.defaultEncryption) === -1) {
+ throw new ConfigurationError('Invalid default encryption');
+ } else {
+ config.defaultEncryption = 'auto';
+ }
+
+ this._config = config;
+ }
+} \ No newline at end of file
diff --git a/src/Client/PasswordsClient.js b/src/Client/PasswordsClient.js
new file mode 100644
index 0000000..24e40b6
--- /dev/null
+++ b/src/Client/PasswordsClient.js
@@ -0,0 +1,24 @@
+import BasicClassLoader from "../ClassLoader/BasicClassLoader";
+import DefaultClassLoader from "../ClassLoader/DefaultClassLoader";
+import BasicPasswordsClient from "./BasicPasswordsClient";
+
+export default class PasswordsClient extends BasicPasswordsClient {
+
+ /**
+ *
+ * @param {(Object|Server)} server
+ * @param {Object} [config={}]
+ * @param {(Object|BasicClassLoader|DefaultClassLoader)} [classes={}]
+ */
+ constructor(server, config = {}, classes = {}) {
+ if(!(classes instanceof BasicClassLoader)) {
+ classes = new DefaultClassLoader(classes);
+ }
+
+ if(!server.getApiUrl || typeof server.getApiUrl !== "function") {
+ server = classes.getInstance('model.server', server)
+ }
+
+ super(server, config, classes);
+ }
+} \ No newline at end of file
diff --git a/src/Collection/AbstractCollection.js b/src/Collection/AbstractCollection.js
new file mode 100644
index 0000000..d7de25c
--- /dev/null
+++ b/src/Collection/AbstractCollection.js
@@ -0,0 +1,172 @@
+import AbstractModel from '../Model/AbstractModel';
+
+export default class AbstractCollection {
+
+ /**
+ * @return {Number}
+ */
+ get length() {
+ return this._elements.length;
+ }
+
+ /**
+ *
+ * @param {AbstractConverter} converter
+ * @param {AbstractModel} elements
+ */
+ constructor(converter, ...elements) {
+ /** @type AbstractConverter **/
+ this._converter = converter;
+
+ /** @type AbstractModel[] **/
+ this._elements = this._getParamArray(elements);
+ }
+
+ /**
+ * @param elements
+ * @api
+ */
+ add(...elements) {
+ elements = this._getParamArray(elements);
+
+ for(let element of elements) {
+ this._addElement(element);
+ }
+ }
+
+ /**
+ * @param {(String|AbstractModel)} ids
+ * @api
+ */
+ remove(...ids) {
+ ids = this._getParamArray(ids);
+
+ for(let id of ids) {
+ if(typeof id === 'string') {
+ this._removeElement(id);
+ } else {
+ this._removeElement(id.getId());
+ }
+ }
+ }
+
+ /**
+ * @param {(String|AbstractModel)} id
+ * @api
+ */
+ has(id) {
+ id = typeof id === 'string' ? id:id.getId();
+
+ for(let element of this._elements) {
+ if(element.getId() === id) return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param {(Number|String)} index
+ * @return {AbstractModel|string|null}
+ * @api
+ */
+ get(index) {
+ if(this._elements.hasOwnProperty(index)) {
+ return this._elements[index];
+ }
+
+ for(let element of this._elements) {
+ if(element.getId() === index) return element;
+ }
+
+ return null;
+ }
+
+ /**
+ *
+ * @param {(AbstractModel|AbstractModel[])} elements
+ */
+ replaceAll(...elements) {
+ /** @type AbstractModel[] **/
+ this._elements = this._getParamArray(elements);
+ }
+
+ /**
+ * @param {(String|Object|AbstractModel)} element
+ * @private
+ */
+ _addElement(element) {
+ if(typeof element === 'string') {
+ this._elements.push(this._converter.fromJSON(element));
+ } else if(element instanceof AbstractModel) {
+ this._elements.push(element);
+ } else if(element instanceof Object) {
+ this._elements.push(this._converter.fromObject(element));
+ } else {
+ throw new Error('Element is not processable');
+ }
+ }
+
+ /**
+ * @param {String} id
+ * @private
+ */
+ _removeElement(id) {
+ for(let i = 0; i < this._elements.length; i++) {
+ if(this._elements[i].getId() === id) {
+ this._elements.splice(i, 1);
+ i--;
+ }
+ }
+ }
+
+ /**
+ * @param {(Array|Object|String)} parameter
+ * @return {Array}
+ * @private
+ */
+ _getParamArray(parameter) {
+ if(Array.isArray(parameter)) {
+ if(parameter.length === 1) {
+ let element = parameter.pop();
+ return Array.isArray(element) ? element:[element];
+ }
+
+ return parameter;
+ }
+
+ return [parameter];
+ }
+
+ /**
+ * @return {Object}
+ */
+ toJSON() {
+ let json = [];
+
+ for(let element of this._elements) {
+ json.push(this._converter.toObject(element));
+ }
+
+ return json;
+ }
+
+ [Symbol.iterator]() {
+ let index = 0;
+ return {
+ /**
+ * @return {{value: AbstractModel, done: boolean}|{done: boolean}}
+ */
+ next: () => {
+ if(index < this._elements.length) {
+ return {
+ value: this._elements[index++],
+ done : false
+ };
+ } else {
+ index = 0;
+ return {done: true};
+ }
+ }
+ };
+ }
+} \ No newline at end of file
diff --git a/src/Collection/CustomFieldCollection.js b/src/Collection/CustomFieldCollection.js
new file mode 100644
index 0000000..f73dcd5
--- /dev/null
+++ b/src/Collection/CustomFieldCollection.js
@@ -0,0 +1,4 @@
+import AbstractCollection from './AbstractCollection';
+
+export default class CustomFieldCollection extends AbstractCollection {
+} \ No newline at end of file
diff --git a/src/Collection/FolderCollection.js b/src/Collection/FolderCollection.js
new file mode 100644
index 0000000..a48fd91
--- /dev/null
+++ b/src/Collection/FolderCollection.js
@@ -0,0 +1,4 @@
+import AbstractCollection from './AbstractCollection';
+
+export default class FolderCollection extends AbstractCollection {
+} \ No newline at end of file
diff --git a/src/Collection/PasswordCollection.js b/src/Collection/PasswordCollection.js
new file mode 100644
index 0000000..52bec5e
--- /dev/null
+++ b/src/Collection/PasswordCollection.js
@@ -0,0 +1,4 @@
+import AbstractCollection from './AbstractCollection';
+
+export default class PasswordCollection extends AbstractCollection {
+} \ No newline at end of file
diff --git a/src/Collection/SettingCollection.js b/src/Collection/SettingCollection.js
new file mode 100644
index 0000000..6e7acff
--- /dev/null
+++ b/src/Collection/SettingCollection.js
@@ -0,0 +1,49 @@
+import AbstractCollection from './AbstractCollection';
+
+export default class SettingCollection extends AbstractCollection {
+
+
+ /**
+ * @param {(Number|String)} index
+ * @return {AbstractModel|string|null}
+ * @api
+ */
+ get(index) {
+ if(this._elements.hasOwnProperty(index)) {
+ return this._elements[index];
+ }
+
+ for(let element of this._elements) {
+ if(element.getId() === index || element.getName() === index) return element;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param {(String|AbstractModel)} id
+ * @api
+ */
+ has(id) {
+ id = typeof id === 'string' ? id:id.getId();
+
+ for(let element of this._elements) {
+ if(element.getId() === id || element.getName() === id) return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param {String} id
+ * @private
+ */
+ _removeElement(id) {
+ for(let i = 0; i < this._elements.length; i++) {
+ if(this._elements[i].getId() === id || this._elements[i].getName() === id) {
+ this._elements.splice(i, 1);
+ i--;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Collection/TagCollection.js b/src/Collection/TagCollection.js
new file mode 100644
index 0000000..2002e9a
--- /dev/null
+++ b/src/Collection/TagCollection.js
@@ -0,0 +1,4 @@
+import AbstractCollection from './AbstractCollection';
+
+export default class TagCollection extends AbstractCollection {
+} \ No newline at end of file
diff --git a/src/Configuration/DataField.json b/src/Configuration/DataField.json
new file mode 100644
index 0000000..4ecd5fc
--- /dev/null
+++ b/src/Configuration/DataField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,369}"
+ },
+ "type": {
+ "type": "string",
+ "match": "data"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,369}"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/EmailField.json b/src/Configuration/EmailField.json
new file mode 100644
index 0000000..066a38d
--- /dev/null
+++ b/src/Configuration/EmailField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,48}"
+ },
+ "type": {
+ "type": "string",
+ "match": "email"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,320}"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/FileField.json b/src/Configuration/FileField.json
new file mode 100644
index 0000000..2a0bd59
--- /dev/null
+++ b/src/Configuration/FileField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,48}"
+ },
+ "type": {
+ "type": "string",
+ "match": "file"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,320}"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/Folder.json b/src/Configuration/Folder.json
new file mode 100644
index 0000000..4c8b3e3
--- /dev/null
+++ b/src/Configuration/Folder.json
@@ -0,0 +1,85 @@
+{
+ "id": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "revision": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "label": {
+ "type": "string",
+ "match": ".+{1,64}",
+ "writeable": true
+ },
+ "parent": {
+ "type": "Folder",
+ "match": "",
+ "writeable": true
+ },
+ "parentId": {
+ "type": "Folder",
+ "match": "",
+ "writeable": true
+ },
+ "hidden": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "trashed": {
+ "type": "boolean",
+ "writeable": false
+ },
+ "favorite": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "cseType": {
+ "type": "string",
+ "match": "none|CSEv1r1",
+ "writeable": true
+ },
+ "cseKey": {
+ "type": "string",
+ "match": "",
+ "writeable": true
+ },
+ "sseType": {
+ "type": "string",
+ "match": "none|SSEv1r1|SSEv1r2|SSEv2r1",
+ "writeable": false
+ },
+ "client": {
+ "type": "string",
+ "writeable": false
+ },
+ "edited": {
+ "type": "date",
+ "writeable": true
+ },
+ "created": {
+ "type": "date",
+ "writeable": false
+ },
+ "updated": {
+ "type": "date",
+ "writeable": false
+ },
+ "revisions": {
+ "type": "FolderCollection",
+ "match": "",
+ "writeable": true
+ },
+ "passwords": {
+ "type": "PasswordCollection",
+ "match": "",
+ "writeable": true
+ },
+ "folders": {
+ "type": "FolderCollection",
+ "match": "",
+ "writeable": true
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/Password.json b/src/Configuration/Password.json
new file mode 100644
index 0000000..2c8a3d9
--- /dev/null
+++ b/src/Configuration/Password.json
@@ -0,0 +1,138 @@
+{
+ "id": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "revision": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "label": {
+ "type": "string",
+ "match": ".+{1,64}",
+ "writeable": true
+ },
+ "username": {
+ "type": "string",
+ "match": ".+{0,64}",
+ "writeable": true
+ },
+ "password": {
+ "type": "string",
+ "match": ".+{3,256}",
+ "writeable": true
+ },
+ "url": {
+ "type": "url",
+ "writeable": true
+ },
+ "notes": {
+ "type": "string",
+ "match": ".+{0,8192}",
+ "writeable": true
+ },
+ "customFields": {
+ "type": "json",
+ "validate": "CustomFields",
+ "writeable": true
+ },
+ "status": {
+ "type": "number",
+ "min": 0,
+ "max": 2,
+ "writeable": false
+ },
+ "statusCode": {
+ "type": "string",
+ "match": "GOOD|OUTDATED|DUPLICATE|BREACHED",
+ "writeable": false
+ },
+ "hash": {
+ "type": "string",
+ "match": ".+{0,32}",
+ "writeable": true
+ },
+ "folderId": {
+ "type": "string",
+ "match": "",
+ "writeable": true
+ },
+ "share": {
+ "type": ["null", "string", "json"],
+ "validate": "Share",
+ "writeable": false
+ },
+ "cseType": {
+ "type": "string",
+ "match": "none|CSEv1r1",
+ "writeable": true
+ },
+ "cseKey": {
+ "type": "string",
+ "match": "",
+ "writeable": true
+ },
+ "sseType": {
+ "type": "string",
+ "match": "none|SSEv1r1|SSEv1r2|SSEv2r1",
+ "writeable": false
+ },
+ "client": {
+ "type": "string",
+ "writeable": false
+ },
+ "shared": {
+ "type": "boolean",
+ "writeable": false
+ },
+ "hidden": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "trashed": {
+ "type": "boolean",
+ "writeable": false
+ },
+ "favorite": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "editable": {
+ "type": "boolean",
+ "writeable": false
+ },
+ "edited": {
+ "type": "date",
+ "writeable": true
+ },
+ "created": {
+ "type": "date",
+ "writeable": false
+ },
+ "updated": {
+ "type": "date",
+ "writeable": false
+ },
+ "folder": {
+ "type": "Folder",
+ "match": "",
+ "writeable": true
+ },
+ "revisions": {
+ "type": "PasswordCollection",
+ "match": "",
+ "writeable": true
+ },
+ "passwords": {
+ "type": "PasswordCollection",
+ "match": "",
+ "writeable": true
+ },
+ "tags": {
+ "type": "TagCollection",
+ "match": "",
+ "writeable": true
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/SecretField.json b/src/Configuration/SecretField.json
new file mode 100644
index 0000000..15a5d15
--- /dev/null
+++ b/src/Configuration/SecretField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,48}"
+ },
+ "type": {
+ "type": "string",
+ "match": "secret"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,320}"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/Server.json b/src/Configuration/Server.json
new file mode 100644
index 0000000..ea84041
--- /dev/null
+++ b/src/Configuration/Server.json
@@ -0,0 +1,12 @@
+{
+ "baseUrl":{
+ "type": "string",
+ "match": "https:\/\/.+"
+ },
+ "user":{
+ "type": "string"
+ },
+ "token":{
+ "type": "string"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/Tag.json b/src/Configuration/Tag.json
new file mode 100644
index 0000000..335870e
--- /dev/null
+++ b/src/Configuration/Tag.json
@@ -0,0 +1,75 @@
+{
+ "id": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "revision": {
+ "type": "string",
+ "match": "",
+ "writeable": false
+ },
+ "label": {
+ "type": "string",
+ "match": ".+{1,64}",
+ "writeable": true
+ },
+ "color": {
+ "type": "string",
+ "match": ".+{1,7}",
+ "writeable": true
+ },
+ "hidden": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "trashed": {
+ "type": "boolean",
+ "writeable": false
+ },
+ "favorite": {
+ "type": "boolean",
+ "writeable": true
+ },
+ "cseType": {
+ "type": "string",
+ "match": "none|CSEv1r1",
+ "writeable": true
+ },
+ "cseKey": {
+ "type": "string",
+ "match": "",
+ "writeable": true
+ },
+ "sseType": {
+ "type": "string",
+ "match": "none|SSEv1r1|SSEv1r2|SSEv2r1",
+ "writeable": false
+ },
+ "client": {
+ "type": "string",
+ "writeable": false
+ },
+ "edited": {
+ "type": "date",
+ "writeable": true
+ },
+ "created": {
+ "type": "date",
+ "writeable": false
+ },
+ "updated": {
+ "type": "date",
+ "writeable": false
+ },
+ "revisions": {
+ "type": "TagCollection",
+ "match": "",
+ "writeable": true
+ },
+ "passwords": {
+ "type": "PasswordCollection",
+ "match": "",
+ "writeable": true
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/TextField.json b/src/Configuration/TextField.json
new file mode 100644
index 0000000..2e874de
--- /dev/null
+++ b/src/Configuration/TextField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,48}"
+ },
+ "type": {
+ "type": "string",
+ "match": "text"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,320}"
+ }
+} \ No newline at end of file
diff --git a/src/Configuration/UrlField.json b/src/Configuration/UrlField.json
new file mode 100644
index 0000000..e2d8b1b
--- /dev/null
+++ b/src/Configuration/UrlField.json
@@ -0,0 +1,14 @@
+{
+ "label": {
+ "type": "string",
+ "match": ".+{1,48}"
+ },
+ "type": {
+ "type": "string",
+ "match": "url"
+ },
+ "value": {
+ "type": "string",
+ "match": ".+{1,320}"
+ }
+} \ No newline at end of file
diff --git a/src/Converter/AbstractConverter.js b/src/Converter/AbstractConverter.js
new file mode 100644
index 0000000..acc7bb7
--- /dev/null
+++ b/src/Converter/AbstractConverter.js
@@ -0,0 +1,145 @@
+import ObjectClone from '../Utility/ObjectClone';
+
+export default class AbstractConverter {
+
+ /**
+ * @param {BasicPasswordsClient} client
+ * @param {String} type
+ */
+ constructor(client, type) {
+ this._client = client;
+ this._type = type;
+ }
+
+ /**
+ * @param {String} string
+ * @return {AbstractRevisionModel}
+ * @api
+ */
+ fromJSON(string) {
+ let data = JSON.parse(string);
+
+ if(typeof data === 'object' && data instanceof Object) {
+ return this.fromObject(data);
+ }
+
+ throw new Error('Invalid JSON string');
+ }
+
+ /**
+ * @param {Object} object
+ * @return {AbstractRevisionModel}
+ * @api
+ */
+ fromObject(object) {
+ let clone = ObjectClone.clone(object);
+
+ if(clone.hasOwnProperty('created') && !(clone.created instanceof Date)) {
+ clone.created = new Date(clone.created * 1e3);
+ }
+ if(clone.hasOwnProperty('updated') && !(clone.updated instanceof Date)) {
+ clone.updated = new Date(clone.updated * 1e3);
+ }
+ if(clone.hasOwnProperty('edited') && !(clone.edited instanceof Date)) {
+ clone.edited = new Date(clone.edited * 1e3);
+ }
+
+ return this.makeModel(clone);
+ }
+
+ /**
+ *
+ * @param properties
+ * @return {*}
+ */
+ fromModel(model) {
+ // @TODO actually clone models
+ return model;
+ }
+
+ /**
+ *
+ * @param properties
+ * @return {*}
+ */
+ makeModel(properties) {
+ return this._client.getClass(`model.${this._type}`, properties);
+ }
+
+ /**
+ * @param {Object} data
+ * @return {Promise<AbstractRevisionModel>}
+ * @api
+ */
+ async fromEncryptedData(data) {
+ if(data.hasOwnProperty('cseType')) {
+ if(data.cseType === 'CSEv1r1') {
+ data = await this._client.getCseV1Encryption().decrypt(data, this._type);
+ } else if(data.cseType !== 'none') {
+ throw this._client.getClass('exception.encryption.unsupported', data, 'CSEv1r1');
+ }
+ }
+
+ return this.fromObject(data);
+ }
+
+ /**
+ * @param {AbstractRevisionModel} model
+ * @return {Promise<Object>}
+ * @api
+ */
+ async toEncryptedData(model) {
+ let data = await this.toObject(model);
+
+ if(data.cseType === 'none') {
+ return await this._client.getInstance('encryption.none').encrypt(data, this._type);
+ }
+
+ if(data.cseType === 'CSEv1r1') {
+ return await this._client.getCseV1Encryption().encrypt(data, this._type);
+ }
+
+ return await this._client.getDefaultEncryption().encrypt(data, this._type);
+ }
+
+ /**
+ * @param {AbstractRevisionModel} model
+ * @return {Promise<Object>}
+ * @api
+ */
+ async toApiObject(model) {
+ let data = await this.toEncryptedData(model),
+ properties = model.getPropertyConfiguration();
+
+ for(let key in data) {
+ if(!properties.hasOwnProperty(key) || !properties[key].writeable) {
+ delete data[key];
+ }
+ }
+
+ let id = model.getId();
+ if(typeof id === 'string' && id.length === 36) {
+ data.id = id;
+ }
+
+ return data;
+ }
+
+ /**
+ * @param {AbstractRevisionModel} model
+ * @return {Object}
+ * @api
+ */
+ toObject(model) {
+ return model.getProperties();
+ }
+
+ /**
+ * @param {AbstractRevisionModel} model
+ * @return {String}
+ * @api
+ */
+ toJSON(model) {
+ return JSON.stringify(model);
+ }
+} \ No newline at end of file
diff --git a/src/Converter/CustomFieldConverter.js b/src/Converter/CustomFieldConverter.js
new file mode 100644
index 0000000..9ab2ff7
--- /dev/null
+++ b/src/Converter/CustomFieldConverter.js
@@ -0,0 +1,88 @@
+export default class CustomFieldConverter {
+
+ /**
+ * @param {BasicPasswordsClient} client
+ */
+ constructor(client) {
+ this._client = client;
+ }
+
+ /**
+ * @param {String} string
+ *
+ * @return {(CustomFieldCollection|AbstractField)}
+ * @api
+ */
+ fromJSON(string) {
+ let data = JSON.parse(string);
+
+ if(Array.isArray(data)) {
+ return this.fromArray(data);
+ }
+
+ if(typeof data === 'object' && data instanceof Object) {
+ return this.fromObject(data);
+ }
+ }
+
+ /**
+ * @param {Object} object
+ * @return {AbstractField}
+ * @api
+ */
+ fromObject(object) {
+ if(!object.hasOwnProperty('type') || object.type === null || object.type === undefined) {
+ this._client.getLogger().error('Found invalid custom field', {field: object});
+ this._client.getClass(`model.defectField`, {label:'##ERROR##', value:JSON.stringify(object)})
+ }
+
+ let type = object.type;
+
+ return this._client.getClass(`model.${type}Field`, object);
+ }
+
+ /**
+ * @param {Object[]} array
+ * @return {CustomFieldCollection}
+ * @api
+ */
+ fromArray(array) {
+ let fields = [];
+
+ for(let field of array) {
+ fields.push(this.fromObject(field));
+ }
+
+ return this._client.getClass('collection.field', fields);
+ }
+
+ /**
+ * @param {CustomFieldCollection} collection
+ * @return {Object[]}
+ */
+ toArray(collection) {
+ let array = [];
+
+ for(let field of collection) {
+ array.push(this.toObject(field));
+ }
+
+ return array;
+ }
+
+ /**
+ * @param {AbstractField} field
+ * @return {Object}
+ */
+ toObject(field) {
+ return field.getProperties();
+ }
+
+ /**
+ * @param {AbstractField} field
+ * @return {String}
+ */
+ toJSON(field) {
+ return JSON.stringify(field);
+ }
+} \ No newline at end of file
diff --git a/src/Converter/FolderConverter.js b/src/Converter/FolderConverter.js
new file mode 100644
index 0000000..0eaa233
--- /dev/null
+++ b/src/Converter/FolderConverter.js
@@ -0,0 +1,11 @@
+import AbstractConverter from './AbstractConverter';
+
+export default class FolderConverter extends AbstractConverter {
+
+ /**
+ * @param {BasicPasswordsClient} client
+ */
+ constructor(client) {
+ super(client, 'folder');
+ }
+} \ No newline at end of file
diff --git a/src/Converter/PasswordConverter.js b/src/Converter/PasswordConverter.js
new file mode 100644
index 0000000..e5ff2ef
--- /dev/null
+++ b/src/Converter/PasswordConverter.js
@@ -0,0 +1,61 @@
+import AbstractConverter from './AbstractConverter';
+import ObjectClone from '../Utility/ObjectClone';
+
+export default class PasswordConverter extends AbstractConverter {
+
+ /**
+ * @param {BasicPasswordsClient} client
+ */
+ constructor(client) {
+ super(client, 'password');
+ /** @type {CustomFieldConverter} **/
+ this._customFieldConverter = this._client.getInstance('converter.field');
+ this._hashService = /** @type {HashService} **/ this._client.getInstance('service.hash');
+ }
+
+ /**
+ * @param {Object} object
+ * @return {AbstractRevisionModel}
+ */
+ fromObject(object) {
+ let clone = ObjectClone.clone(object);
+
+ if(typeof clone.customFields === 'string') {
+ try {
+ clone.customFields = this._customFieldConverter.fromJSON(clone.customFields);
+ } catch(e) {
+ this._client.getLogger().warning('Could not read custom fields', {password: object});
+ this._client.getLogger().exception(e, {password: object});
+ clone.customFields = this._customFieldConverter.fromArray([]);
+ }
+ } else {
+ clone.customFields = this._customFieldConverter.fromArray([]);
+ }
+
+ if(clone.hasOwnProperty('created') && !(clone.created instanceof Date)) {
+ clone.created = new Date(clone.created * 1e3);
+ }
+ if(clone.hasOwnProperty('updated') && !(clone.updated instanceof Date)) {
+ clone.updated = new Date(clone.updated * 1e3);
+ }
+ if(clone.hasOwnProperty('edited') && !(clone.edited instanceof Date)) {
+ clone.edited = new Date(clone.edited * 1e3);
+ }
+
+ return this._client.getClass(`model.${this._type}`, clone);
+ }
+
+ /**
+ *
+ * @param {(Password|AbstractRevisionModel)} model
+ * @returns {Promise<void>}
+ */
+ async toObject(model) {
+ let data = super.toObject(model);
+
+ data.customFields = this._customFieldConverter.toJSON(model.getCustomFields());
+ data.hash = await this._hashService.getHash(model.getPassword(), this._hashService.HASH_SHA_1);
+
+ return data;
+ }
+} \ No newline at end of file
diff --git a/src/Converter/SettingConverter.js b/src/Converter/SettingConverter.js
new file mode 100644
index 0000000..ef9e93c
--- /dev/null
+++ b/src/Converter/SettingConverter.js
@@ -0,0 +1,133 @@
+export default class SettingConverter {
+
+ /**
+ * @param {BasicPasswordsClient} api
+ */
+ constructor(client) {
+ this._client = client;
+ /** @type {Cache} **/
+ this._cache = client.getInstance('cache.cache');
+ }
+
+ /**
+ *
+ * @param {String} json
+ * @return {(SettingCollection|Setting)}
+ */
+ fromJSON(json) {
+ let object = JSON.parse(json);
+
+ if(Array.isArray(object)) {
+ return this.fromArray(object);
+ }
+
+ return this.fromObject(object);
+ }
+
+ /**
+ *
+ * @param {Object} object
+ * @return {Setting}
+ */
+ fromObject(object) {
+ let key = `setting.${object.scope}.${object.name}`;
+
+ if(this._cache.has(key)) {
+ /** @type {Setting} **/
+ let setting = this._cache.get(key);
+ if(setting.getValue() !== object.value) setting.setValue(object.value);
+
+ return setting;
+ }
+
+ let setting = this._client.getClass('model.setting', object.name, object.value, object.scope);
+ this._cache.set(key, setting, 'setting.model');
+
+ return setting;
+ }
+
+ /**
+ *
+ * @param {Object[]} array
+ * @return {SettingCollection}
+ */
+ fromArray(array) {
+ let settings = [];
+
+ for(let setting of array) {
+ settings.push(this.fromObject(setting));
+ }
+
+ return this._client.getClass('collection.setting', settings);
+ }
+
+ /**
+ *
+ * @param {Object} object
+ * @return {SettingCollection}
+ */
+ fromApiObject(object) {
+ let settings = [];
+
+ for(let key in object) {
+ if(!object.hasOwnProperty(key)) continue;
+ let index = key.indexOf('.'),
+ scope = key.substr(0, index),
+ name = key.substring(index + 1),
+ value = object[key];
+
+ settings.push(this.fromObject({scope, name, value}));
+ }
+
+ return this._client.getClass('collection.setting', settings);
+ }
+
+ /**
+ * @param {SettingCollection} collection
+ * @return {Object[]}
+ */
+ toApiObject(collection) {
+ let object = {};
+
+ for(let setting of collection) {
+ let key = `${setting.scope}.${setting.name}`;
+ object[key] = setting.value;
+ }
+
+ return object;
+ }
+
+ /**
+ * @param {SettingCollection} collection
+ * @return {Object[]}
+ */
+ toArray(collection) {
+ let array = [];
+
+ for(let field of collection) {
+ array.push(this.toObject(field));
+ }
+
+ return array;
+ }
+
+ /**
+ * @param {Setting} setting
+ * @return {Object}
+ */
+ toObject(setting) {
+ return {
+ scope: setting.scope,
+ name : setting.name,
+ value: setting.value
+ };
+ }
+
+ /**
+ * @param {Setting} setting
+ * @return {String}
+ */
+ toJSON(setting) {
+ return JSON.stringify(setting);
+ }
+} \ No newline at end of file
diff --git a/src/Converter/TagConverter.js b/src/Converter/TagConverter.js
new file mode 100644
index 0000000..baea7d6
--- /dev/null
+++ b/src/Converter/TagConverter.js
@@ -0,0 +1,11 @@
+import AbstractConverter from './AbstractConverter';
+
+export default class TagConverter extends AbstractConverter {
+
+ /**
+ * @param {BasicPasswordsClient} client
+ */
+ constructor(client) {
+ super(client, 'tag');
+ }
+} \ No newline at end of file
diff --git a/src/Encryption/CSEv1Encryption.js b/src/Encryption/CSEv1Encryption.js
new file mode 100644
index 0000000..10e29a9
--- /dev/null
+++ b/src/Encryption/CSEv1Encryption.js
@@ -0,0 +1,174 @@
+import sodium from 'libsodium-wrappers';
+
+export default class CSEv1Encryption {
+
+ /**
+ * @param {BasicClassLoader} classLoader
+ */
+ constructor(classLoader) {
+ this.fields = {
+ password: ['url', 'label', 'notes', 'password', 'username', 'customFields'],
+ folder : ['label'],
+ tag : ['label', 'color']
+ };
+ this._enabled = classLoader.getClass('state.boolean', false);
+ this._ready = classLoader.getClass('state.boolean', false);
+ this._keychain = null;
+ this._classLoader = classLoader;
+
+ sodium.ready.then(() => {this._ready.set(true);});
+ }
+
+ /**
+ *
+ * @returns {Promise<Boolean>}
+ */
+ async ready() {
+ await this._ready.awaitTrue() && await this._enabled.awaitTrue();
+ return true;
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ enabled() {
+ return this._ready.get() && this._enabled.get();
+ }
+
+ /**
+ * Encrypts an object
+ *
+ * @param {Object} object
+ * @param {String} type
+ * @returns {Object}
+ */
+ async encrypt(object, type) {
+ if(!this.fields.hasOwnProperty(type)) throw this._classLoader.getClass('exception.encryption.object');
+ if(!this.enabled()) throw this._classLoader.getClass('exception.encryption.enabled');
+
+ let fields = this.fields[type],
+ key = this._keychain.getCurrentKey();
+
+ for(let field of fields) {
+ let data = object[field];
+
+ if(data === null || data === undefined || data.length === 0) continue;
+ object[field] = this._encryptString(data, key);
+ }
+
+ object.cseType = 'CSEv1r1';
+ object.cseKey = this._keychain.getCurrentKeyId();
+
+ return object;
+ }
+
+ /**
+ * Decrypts an object
+ *
+ * @param {Object} object
+ * @param {String} type
+ * @returns {Object}
+ */
+ async decrypt(object, type) {
+ if(!this.fields.hasOwnProperty(type)) throw this._classLoader.getClass('exception.encryption.object');
+ if(object.cseType !== 'CSEv1r1') throw this._classLoader.getClass('exception.encryption.unsupported', object, 'CSEv1r1');
+ if(!this.enabled()) throw this._classLoader.getClass('exception.encryption.enabled');
+
+ let fields = this.fields[type],
+ key = this._keychain.getKey(object.cseKey);
+
+ for(let field of fields) {
+ let data = object[field];
+
+ if(data === null || data.length === 0) continue;
+ object[field] = this._decryptString(data, key);
+ }
+
+ return object;
+ }
+
+ /**
+ * Encrypt the message with the given key and return a hex encoded string
+ *
+ * @param {String} message
+ * @param {Uint8Array} key
+ * @returns {String}
+ * @private
+ */
+ _encryptString(message, key) {
+ return sodium.to_hex(this._encrypt(message, key));
+ }
+
+ /**
+ * Decrypt the hex or base64 encoded message with the given key
+ *
+ * @param {String} encodedString
+ * @param {Uint8Array} key
+ * @returns {String}
+ * @private
+ */
+ _decryptString(encodedString, key) {
+ try {
+ let encryptedString = sodium.from_hex(encodedString);
+ return sodium.to_string(this._decrypt(encryptedString, key));
+ } catch(e) {
+ let encryptedString = sodium.from_base64(encodedString);
+ return sodium.to_string(this._decrypt(encryptedString, key));
+ }
+ }
+
+ /**
+ * Encrypt the message with the given key
+ *
+ * @param {Uint8Array} message
+ * @param {Uint8Array} key
+ * @returns {Uint8Array}
+ * @private
+ */
+ _encrypt(message, key) {
+ let nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+
+ return new Uint8Array([...nonce, ...sodium.crypto_secretbox_easy(message, nonce, key)]);
+ }
+
+ /**
+ * Decrypt the message with the given key
+ *
+ * @param {Uint8Array} encrypted
+ * @param {Uint8Array} key
+ * @returns {Uint8Array}
+ * @private
+ */
+ _decrypt(encrypted, key) {
+ if(encrypted.length < sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) throw new Error('Invalid encrypted text length');
+
+ let nonce = encrypted.slice(0, sodium.crypto_secretbox_NONCEBYTES),
+ ciphertext = encrypted.slice(sodium.crypto_secretbox_NONCEBYTES);
+
+ return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
+ }
+
+ /**
+ * Decrypt and activate the keychain
+ *
+ * @param {CSEv1Keychain} keychain
+ * @private
+ */
+ setKeychain(keychain) {
+ this._keychain = keychain;
+
+ keychain
+ .ready()
+ .then(() => {
+ this._enabled.set(true);
+ });
+ }
+
+ /**
+ * Remove the current keychain
+ */
+ unsetKeychain() {
+ this._enabled.set(false);
+ this._keychain = null;
+ }
+} \ No newline at end of file
diff --git a/src/Encryption/ExportV1Encryption.js b/src/Encryption/ExportV1Encryption.js
new file mode 100644
index 0000000..4cd743e
--- /dev/null
+++ b/src/Encryption/ExportV1Encryption.js
@@ -0,0 +1,36 @@
+import sodium from 'libsodium-wrappers';
+import CSEv1Encryption from './CSEv1Encryption';
+
+export default class ExportV1Encryption extends CSEv1Encryption {
+
+ /**
+ * Encrypts the message with the user defined password
+ *
+ * @param message
+ * @param password
+ * @returns {*}
+ */
+ encryptWithPassword(message, password) {
+ let salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES),
+ key = this._passwordToKey(password, salt),
+ encrypted = this._encrypt(message, key);
+
+ return sodium.to_hex(new Uint8Array([...salt, ...encrypted]));
+ }
+
+ /**
+ * Decrypts the message with the user defined password
+ *
+ * @param message
+ * @param password
+ * @returns {*}
+ */
+ decryptWithPassword(message, password) {
+ let encrypted = sodium.from_hex(message),
+ salt = encrypted.slice(0, sodium.crypto_pwhash_SALTBYTES),
+ text = encrypted.slice(sodium.crypto_pwhash_SALTBYTES),
+ key = this._passwordToKey(password, salt);
+
+ return sodium.to_string(this._decrypt(text, key));
+ }
+} \ No newline at end of file
diff --git a/src/Encryption/Keychain/CSEv1Keychain.js b/src/Encryption/Keychain/CSEv1Keychain.js
new file mode 100644
index 0000000..b80ad63
--- /dev/null
+++ b/src/Encryption/Keychain/CSEv1Keychain.js
@@ -0,0 +1,190 @@
+import sodium from 'libsodium-wrappers';
+import uuid from 'uuidv4';
+import BooleanState from '../../State/BooleanState';
+
+export default class CSEv1Keychain {
+
+ /**
+ *
+ * @param {BasicClassLoader} classLoader
+ * @param {String} keychain
+ * @param {String} password
+ */
+ constructor(classLoader, keychain = null, password = null) {
+ this._keys = {};
+ this._current = null;
+ this._enabled = classLoader.getClass('state.boolean', false);
+ this._password = password;
+ this._classLoader = classLoader;
+
+ if(keychain !== null) {
+ sodium.ready.then(() => {
+ this.import(keychain);
+ });
+ }
+ }
+
+ /**
+ *
+ * @returns {Promise<boolean>}
+ */
+ async ready() {
+ return await this._enabled.awaitTrue();
+ }
+
+ /**
+ * Set the password to encrypt/decrypt the keychain
+ * @param value
+ * @returns {CSEv1Keychain}
+ */
+ setPassword(value) {
+ this._password = value;
+
+ return this;
+ }
+
+ /**
+ * Get a key by id
+ *
+ * @param id
+ * @returns {String}
+ */
+ getKey(id) {
+ if(this._keys.hasOwnProperty(id)) {
+ return this._keys[id];
+ }
+
+ throw this._classLoader.getClass('exception.encryption.key.missing', id);
+ }
+
+ /**
+ * Get the current key
+ *
+ * @returns {String}
+ */
+ getCurrentKey() {
+ return this.getKey(this._current);
+ }
+
+ /**
+ * Get the current key
+ *
+ * @returns {(String|null)}
+ */
+ getCurrentKeyId() {
+ return this._current;
+ }
+
+ /**
+ * Decrypt the given keychain and apply it
+ *
+ * @param {String} keychainText
+ */
+ import(keychainText) {
+ let encrypted;
+ try {
+ encrypted = sodium.from_hex(keychainText);
+ } catch(e) {
+ encrypted = sodium.from_base64(keychainText);
+ }
+
+ let salt = encrypted.slice(0, sodium.crypto_pwhash_SALTBYTES),
+ text = encrypted.slice(sodium.crypto_pwhash_SALTBYTES),
+ key = this._passwordToKey(this._password, salt),
+ keychain = JSON.parse(sodium.to_string(this._decrypt(text, key)));
+
+ for(let id in keychain.keys) {
+ if(keychain.keys.hasOwnProperty(id)) {
+ this._keys[id] = sodium.from_hex(keychain.keys[id]);
+ }
+ }
+ this._current = keychain.current;
+ this._enabled.set(true);
+
+ return this;
+ }
+
+ /**
+ * Export the keychain as encrypted string
+ *
+ * @returns {String}
+ */
+ export() {
+ let keychain = {
+ keys : {},
+ current: this._current
+ };
+
+ for(let id in this._keys) {
+ if(this._keys.hasOwnProperty(id)) {
+ keychain.keys[id] = sodium.to_hex(this._keys[id]);
+ }
+ }
+
+ let salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES),
+ key = this._passwordToKey(this._password, salt),
+ encrypted = this._encrypt(JSON.stringify(keychain), key);
+
+ return sodium.to_hex(new Uint8Array([...salt, ...encrypted]));
+ }
+
+ /**
+ * Add a new key to the keychain and set it as current
+ */
+ update() {
+ let uuid = uuid();
+ this._keys[uuid] = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
+ this._current = uuid;
+ this._enabled.set(true);
+ }
+
+ /**
+ * Encrypt the message with the given key
+ *
+ * @param message
+ * @param key
+ * @returns {Uint8Array}
+ */
+ _encrypt(message, key) {
+ let nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
+
+ return new Uint8Array([...nonce, ...sodium.crypto_secretbox_easy(message, nonce, key)]);
+ }
+
+ // noinspection JSMethodCanBeStatic
+ /**
+ * Decrypt the message with the given key
+ *
+ * @param encrypted
+ * @param key
+ * @returns {Uint8Array}
+ */
+ _decrypt(encrypted, key) {
+ let expectedLength = sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES;
+ if(encrypted.length < expectedLength) throw this._classLoader.getClass('exception.encryption.text.length', encrypted.length, expectedLength);
+
+ let nonce = encrypted.slice(0, sodium.crypto_secretbox_NONCEBYTES),
+ ciphertext = encrypted.slice(sodium.crypto_secretbox_NONCEBYTES);
+
+ return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
+ }
+
+ // noinspection JSMethodCanBeStatic
+ /**
+ *
+ * @param password
+ * @param salt
+ * @returns {Uint8Array}
+ * @private
+ */
+ _passwordToKey(password, salt) {
+ return sodium.crypto_pwhash(
+ sodium.crypto_box_SEEDBYTES,
+ password,
+ salt,
+ sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
+ sodium.crypto_pwhash_ALG_DEFAULT
+ );
+ }
+} \ No newline at end of file
diff --git a/src/Encryption/NoEncryption.js b/src/Encryption/NoEncryption.js
new file mode 100644
index 0000000..37d7780
--- /dev/null
+++ b/src/Encryption/NoEncryption.js
@@ -0,0 +1,48 @@
+export default class NoEncryption {
+
+ /**
+ * @param {BasicClassLoader} classLoader
+ */
+ constructor(classLoader) {
+ this._classLoader = classLoader;
+ }
+
+ /**
+ * @returns {Promise<Boolean>}
+ */
+ async ready() {
+ return true;
+ }
+
+ /**
+ * @returns {Boolean}
+ */
+ enabled() {
+ return true;
+ }
+
+ /**
+ * Encrypts an object
+ *
+ * @param {Object} object
+ * @param {String} type
+ * @returns {Object}
+ */
+ async encrypt(object, type) {
+ object.cseType = 'none';
+ object.cseKey = '';
+ return object;
+ }
+
+ /**
+ * Decrypts an object
+ *
+ * @param {Object} object
+ * @param {String} type
+ * @returns {Object}
+ */
+ async decrypt(object, type) {
+ if(object.cseType !== 'none') throw this._classLoader.getClass('exception.encryption.unsupported', object, 'none');
+ return object;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/ChallengeTypeNotSupported.js b/src/Exception/ChallengeTypeNotSupported.js
new file mode 100644
index 0000000..5c57223
--- /dev/null
+++ b/src/Exception/ChallengeTypeNotSupported.js
@@ -0,0 +1,16 @@
+export default class ChallengeTypeNotSupported extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ChallengeTypeNotSupported';
+ }
+
+ /**
+ *
+ */
+ constructor() {
+ super('The required authentication challenge is not supported by this client');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/ConfigruationError.js b/src/Exception/ConfigruationError.js
new file mode 100644
index 0000000..12bb57e
--- /dev/null
+++ b/src/Exception/ConfigruationError.js
@@ -0,0 +1,9 @@
+export default class ConfigurationError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ConfigurationError';
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Encryption/EncryptionNotEnabledError.js b/src/Exception/Encryption/EncryptionNotEnabledError.js
new file mode 100644
index 0000000..6f6bad9
--- /dev/null
+++ b/src/Exception/Encryption/EncryptionNotEnabledError.js
@@ -0,0 +1,13 @@
+export default class EncryptionNotEnabledError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'EncryptionNotEnabledError';
+ }
+
+ constructor() {
+ super(`CSE Encryption not enabled or ready`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Encryption/InvalidEncryptedTextLength.js b/src/Exception/Encryption/InvalidEncryptedTextLength.js
new file mode 100644
index 0000000..8760a11
--- /dev/null
+++ b/src/Exception/Encryption/InvalidEncryptedTextLength.js
@@ -0,0 +1,17 @@
+export default class InvalidEncryptedTextLength extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InvalidEncryptedTextLength';
+ }
+
+ /**
+ * @param {Number} length
+ * @param {Number} expectedLength
+ */
+ constructor(length, expectedLength) {
+ super(`Invalid encrypted text length. Expected ${expectedLength}, got ${length} instead.`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Encryption/InvalidObjectTypeError.js b/src/Exception/Encryption/InvalidObjectTypeError.js
new file mode 100644
index 0000000..260e6b7
--- /dev/null
+++ b/src/Exception/Encryption/InvalidObjectTypeError.js
@@ -0,0 +1,16 @@
+export default class InvalidObjectTypeError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InvalidObjectTypeError';
+ }
+
+ /**
+ * @param {String} type
+ */
+ constructor(type) {
+ super(`Invalid Object Type "${type}"`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Encryption/MissingEncryptionKeyError.js b/src/Exception/Encryption/MissingEncryptionKeyError.js
new file mode 100644
index 0000000..d753b06
--- /dev/null
+++ b/src/Exception/Encryption/MissingEncryptionKeyError.js
@@ -0,0 +1,16 @@
+export default class MissingEncryptionKeyError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'MissingEncryptionKeyError';
+ }
+
+ /**
+ * @param {String} id
+ */
+ constructor(id) {
+ super(`Requested encryption key ${id} not found`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Encryption/UnsupportedEncryptionTypeError.js b/src/Exception/Encryption/UnsupportedEncryptionTypeError.js
new file mode 100644
index 0000000..2858909
--- /dev/null
+++ b/src/Exception/Encryption/UnsupportedEncryptionTypeError.js
@@ -0,0 +1,38 @@
+export default class UnsupportedEncryptionTypeError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'UnsupportedEncryptionTypeError';
+ }
+
+ /**
+ * @returns {String}
+ */
+ get supportedTypes() {
+ return this._supportedTypes;
+ }
+
+ /**
+ * @returns {Object}
+ */
+ get object() {
+ return this._object;
+ }
+
+ /**
+ * @param {Object} object
+ * @param {(String|String[])} supportedTypes
+ */
+ constructor(object, supportedTypes) {
+ if(Array.isArray(supportedTypes)) {
+ supportedTypes = supportedTypes.join(', ');
+ }
+
+ super(`Unsupported encryption type "${object.cseType}" in ${object.id}. Supported types are ${supportedTypes}.`);
+
+ this._object = object;
+ this._supportedTypes = supportedTypes;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/BadGatewayError.js b/src/Exception/Http/BadGatewayError.js
new file mode 100644
index 0000000..6395aaa
--- /dev/null
+++ b/src/Exception/Http/BadGatewayError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class BadGatewayError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'BadGatewayError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Bad Gateway');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/BadRequestError.js b/src/Exception/Http/BadRequestError.js
new file mode 100644
index 0000000..49471fe
--- /dev/null
+++ b/src/Exception/Http/BadRequestError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class BadRequestError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'BadRequestError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Bad Request');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/ForbiddenError.js b/src/Exception/Http/ForbiddenError.js
new file mode 100644
index 0000000..63a79a8
--- /dev/null
+++ b/src/Exception/Http/ForbiddenError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class ForbiddenError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ForbiddenError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Forbidden');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/GatewayTimeoutError.js b/src/Exception/Http/GatewayTimeoutError.js
new file mode 100644
index 0000000..f5bdb0e
--- /dev/null
+++ b/src/Exception/Http/GatewayTimeoutError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class GatewayTimeoutError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'GatewayTimeoutError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Gateway Timeout');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/HttpError.js b/src/Exception/Http/HttpError.js
new file mode 100644
index 0000000..e86dfed
--- /dev/null
+++ b/src/Exception/Http/HttpError.js
@@ -0,0 +1,42 @@
+export default class HttpError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'HttpError';
+ }
+
+ /**
+ * @returns {Response}
+ */
+ get response() {
+ return this._response;
+ }
+
+ /**
+ * @returns {Number}
+ */
+ get status() {
+ return this._status;
+ }
+
+ /**
+ *
+ * @param {Response} response
+ * @param {String} statusText
+ */
+ constructor(response, statusText = '') {
+ let message = `HTTP ${response.status}`;
+
+ if(statusText.length !== 0) {
+ message += ` - ${statusText}`;
+ } else if(response.statusText.length !== 0) {
+ message += ` - ${response.statusText}`;
+ }
+
+ super(message);
+ this._response = response;
+ this._status = response.status;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/InternalServerError.js b/src/Exception/Http/InternalServerError.js
new file mode 100644
index 0000000..7797886
--- /dev/null
+++ b/src/Exception/Http/InternalServerError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class InternalServerError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InternalServerError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Internal Server Error');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/MethodNotAllowedError.js b/src/Exception/Http/MethodNotAllowedError.js
new file mode 100644
index 0000000..38e252c
--- /dev/null
+++ b/src/Exception/Http/MethodNotAllowedError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class MethodNotAllowedError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'MethodNotAllowedError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Method Not Allowed');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/NotFoundError.js b/src/Exception/Http/NotFoundError.js
new file mode 100644
index 0000000..cf0c451
--- /dev/null
+++ b/src/Exception/Http/NotFoundError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class NotFoundError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'NotFoundError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Not Found');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/ServiceUnavailableError.js b/src/Exception/Http/ServiceUnavailableError.js
new file mode 100644
index 0000000..6b0d67b
--- /dev/null
+++ b/src/Exception/Http/ServiceUnavailableError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class ServiceUnavailableError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ServiceUnavailableError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Service Unavailable');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/TooManyRequestsError.js b/src/Exception/Http/TooManyRequestsError.js
new file mode 100644
index 0000000..192f560
--- /dev/null
+++ b/src/Exception/Http/TooManyRequestsError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class TooManyRequestsError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'TooManyRequestsError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Too Many Requests');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/Http/UnauthorizedError.js b/src/Exception/Http/UnauthorizedError.js
new file mode 100644
index 0000000..4d24695
--- /dev/null
+++ b/src/Exception/Http/UnauthorizedError.js
@@ -0,0 +1,18 @@
+import HttpError from './HttpError';
+
+export default class UnauthorizedError extends HttpError {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'UnauthorizedError';
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(response, 'Unauthorized');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/InvalidScopeError.js b/src/Exception/InvalidScopeError.js
new file mode 100644
index 0000000..0970abb
--- /dev/null
+++ b/src/Exception/InvalidScopeError.js
@@ -0,0 +1,16 @@
+export default class InvalidScopeError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InvalidScopeError';
+ }
+
+ /**
+ * @param {String} scope
+ */
+ constructor(scope) {
+ super(`Invalid scope ${scope}`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/NetworkError.js b/src/Exception/NetworkError.js
new file mode 100644
index 0000000..1758fef
--- /dev/null
+++ b/src/Exception/NetworkError.js
@@ -0,0 +1,24 @@
+export default class NetworkError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'NetworkError';
+ }
+
+ /**
+ * @returns {Response}
+ */
+ get response() {
+ return this._response;
+ }
+
+ /**
+ * @param {Response} response
+ */
+ constructor(response) {
+ super(`Network error`);
+ this._response = response;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/PassLink/InvalidLink.js b/src/Exception/PassLink/InvalidLink.js
new file mode 100644
index 0000000..641df63
--- /dev/null
+++ b/src/Exception/PassLink/InvalidLink.js
@@ -0,0 +1,13 @@
+export default class InvalidLink extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InvalidLink';
+ }
+
+ constructor() {
+ super('Invalid PassLink given');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/PassLink/UnknownAction.js b/src/Exception/PassLink/UnknownAction.js
new file mode 100644
index 0000000..f809829
--- /dev/null
+++ b/src/Exception/PassLink/UnknownAction.js
@@ -0,0 +1,16 @@
+export default class InvalidLink extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'InvalidLink';
+ }
+
+ /**
+ * @param {String} action
+ */
+ constructor(action) {
+ super(`Unknown PassLink action ${action}`);
+ }
+} \ No newline at end of file
diff --git a/src/Exception/ResponseContentTypeError.js b/src/Exception/ResponseContentTypeError.js
new file mode 100644
index 0000000..5594343
--- /dev/null
+++ b/src/Exception/ResponseContentTypeError.js
@@ -0,0 +1,27 @@
+export default class ResponseContentTypeError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ResponseContentTypeError';
+ }
+
+ /**
+ * @returns {Response}
+ */
+ get response() {
+ return this._response;
+ }
+
+ /**
+ * @param {String} expectedType
+ * @param {String} actualType
+ * @param {Response} response
+ */
+ constructor(expectedType, actualType, response) {
+ super(`Expected ${expectedType}, got ${actualType}`);
+
+ this._response = response;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/ResponseDecodingError.js b/src/Exception/ResponseDecodingError.js
new file mode 100644
index 0000000..278da73
--- /dev/null
+++ b/src/Exception/ResponseDecodingError.js
@@ -0,0 +1,40 @@
+export default class ResponseDecodingError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'ResponseDecodingError';
+ }
+
+ /**
+ * @returns {Response}
+ */
+ get response() {
+ return this._response;
+ }
+
+ /**
+ * @returns {Error}
+ */
+ get error() {
+ return this._error;
+ }
+
+ /**
+ * @param {Response} response
+ * @param {Error} error
+ * @param {String} message
+ */
+ constructor(response, error, message = '') {
+
+ if(message.length === 0) {
+ message = error.message;
+ }
+
+ super(message);
+
+ this._response = response;
+ this._error = error;
+ }
+} \ No newline at end of file
diff --git a/src/Exception/TokenTypeNotSupported.js b/src/Exception/TokenTypeNotSupported.js
new file mode 100644
index 0000000..cb27fee
--- /dev/null
+++ b/src/Exception/TokenTypeNotSupported.js
@@ -0,0 +1,16 @@
+export default class TokenTypeNotSupported extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'TokenTypeNotSupported';
+ }
+
+ /**
+ *
+ */
+ constructor() {
+ super('None of the available tokens are supported by this client.');
+ }
+} \ No newline at end of file
diff --git a/src/Exception/UnknownPropertyError.js b/src/Exception/UnknownPropertyError.js
new file mode 100644
index 0000000..c0d8f75
--- /dev/null
+++ b/src/Exception/UnknownPropertyError.js
@@ -0,0 +1,34 @@
+export default class UnknownPropertyError extends Error {
+
+ /**
+ * @returns {String}
+ */
+ get name() {
+ return 'UnknownPropertyError';
+ }
+
+ /**
+ * @returns {String}
+ */
+ get item() {
+ return this._item;
+ }
+
+ /**
+ * @returns {String}
+ */
+ get property() {
+ return this._property;
+ }
+
+ /**
+ * @param {String} property
+ * @param {String} item
+ */
+ constructor(property, item) {
+ super(`Attempted access to unknown property ${property}`);
+
+ this._property = property;
+ this._item = item;
+ }
+} \ No newline at end of file
diff --git a/src/Logger/Logger.js b/src/Logger/Logger.js
new file mode 100644
index 0000000..4bb932e
--- /dev/null
+++ b/src/Logger/Logger.js
@@ -0,0 +1,95 @@
+export default class Logger {
+
+ /**
+ * @returns {Number}
+ */
+ get logLevel() {
+ return this._logLevel;
+ }
+
+ /**
+ * @param {Number} value
+ */
+ set logLevel(value) {
+ this._logLevel = value;
+ }
+
+ /**
+ * @param {Number} [logLevel=0]
+ */
+ constructor(logLevel = 0) {
+ this._logLevel = logLevel;
+ }
+
+ /**
+ * @param {String} message
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ debug(message, context = {}) {
+ if(this._logLevel > 0) return this;
+ console.debug(message, context);
+ return this;
+ }
+
+ /**
+ * @param {String} message
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ info(message, context = {}) {
+ if(this._logLevel > 1) return this;
+ console.info(message, context);
+ return this;
+ }
+
+ /**
+ * @param {String} message
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ log(message, context = {}) {
+ if(this._logLevel > 2) return this;
+ console.log(message, context);
+ return this;
+ }
+
+ /**
+ * @param {String} message
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ warning(message, context = {}) {
+ if(this._logLevel > 3) return this;
+ console.warn(message, context);
+ return this;
+ }
+
+ /**
+ * @param {String} message
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ error(message, context = {}) {
+ if(this._logLevel > 4) return this;
+ console.error(message, context);
+ return this;
+ }
+
+ /**
+ * @param {Error} exception
+ * @param {Object} [context={}]
+ *
+ * @returns {Logger}
+ */
+ exception(exception, context = {}) {
+ if(this._logLevel > 4) return this;
+ console.error(exception, context);
+ return this;
+ }
+} \ No newline at end of file
diff --git a/src/Model/AbstractModel.js b/src/Model/AbstractModel.js
new file mode 100644
index 0000000..8b75fe8
--- /dev/null
+++ b/src/Model/AbstractModel.js
@@ -0,0 +1,109 @@
+import UnknownPropertyError from '../Exception/UnknownPropertyError';
+
+export default class AbstractModel {
+
+ /**
+ * @param {Object} properties
+ * @param {Object} [data={}]
+ */
+ constructor(properties, data = {}) {
+ this._properties = properties;
+ this._originalData = {};
+ this._data = {};
+ this.setProperties(data);
+ this._originalData = {};
+ }
+
+ /**
+ *
+ * @param {String} property
+ * @return {boolean}
+ */
+ hasProperty(property) {
+ return this._properties.hasOwnProperty(property);
+ }
+
+ /**
+ * @param {String} property
+ *
+ * @return {*}
+ * @api
+ */
+ getProperty(property) {
+ if(!this.hasProperty(property)) {
+ throw new UnknownPropertyError(property, this);
+ }
+
+ if(!this._data.hasOwnProperty(property)) {
+ return undefined;
+ }
+
+ return this._data[property];
+ }
+
+ /**
+ * @param {String} property
+ * @param {*} value
+ *
+ * @return {this}
+ * @api
+ */
+ setProperty(property, value) {
+ if(!this.hasProperty(property)) {
+ throw new UnknownPropertyError(property, this);
+ }
+
+ this._originalData[property] = this._data[property];
+ this._data[property] = value;
+
+ return this;
+ }
+
+ /**
+ * @return {{}}
+ * @api
+ */
+ getProperties() {
+ let data = {};
+
+ for(let key in this._properties) {
+ if(!this._properties.hasOwnProperty(key)) continue;
+
+ data[key] = this.getProperty(key);
+ }
+
+ return data;
+ }
+
+ /**
+ * @param {Object} properties
+ *
+ * @return {this}
+ * @api
+ */
+ setProperties(properties) {
+ for(let key in properties) {
+ if(!properties.hasOwnProperty(key)) continue;
+
+ this.setProperty(key, properties[key]);
+ }
+
+ return this;
+ }
+
+ /**
+ * @return {Object}
+ */
+ getPropertyConfiguration() {
+ return this._properties;
+ }
+
+ /**
+ *
+ * @return {{}}
+ * @api
+ */
+ toJSON() {
+ return this.getProperties();
+ }
+} \ No newline at end of file
diff --git a/src/Model/AbstractRevisionModel.js b/src/Model/AbstractRevisionModel.js
new file mode 100644
index 0000000..b565f18
--- /dev/null
+++ b/src/Model/AbstractRevisionModel.js
@@ -0,0 +1,287 @@
+import AbstractModel from './AbstractModel';
+
+export default class AbstractRevisionModel extends AbstractModel {
+
+ constructor(properties, data) {
+ super(properties, data);
+ this._detailLevel = [];
+ }
+
+ /**
+ * @return {String[]}
+ */
+ getDetailLevel() {
+ return this._detailLevel;
+ }
+
+ /**
+ * @param {String[]} value
+ * @return {AbstractRevisionModel}
+ */
+ setDetailLevel(value) {
+ return this._detailLevel;
+ }
+
+ /**
+ * @return {String}
+ * @api
+ */
+ getId() {
+ return this.getProperty('id');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {this}
+ * @api
+ */
+ setId(value) {
+ return this.setProperty('id', value);
+ }
+
+ /**
+ * @return {string}
+ * @api
+ */
+ getRevision() {
+ return this.getProperty('revision');
+ }
+
+ /**
+ * @param {string} value
+ *
+ * @return {this}
+ * @api
+ */
+ setRevision(value) {
+ return this.setProperty('revision', value);
+ }
+
+ /**
+ * @return {string}
+ * @api
+ */
+ getCseType() {
+ return this.getProperty('cseType');
+ }
+
+ /**
+ * @param {string} value
+ *
+ * @return {this}
+ * @api
+ */
+ setCseType(value) {
+ return this.setProperty('cseType', value);
+ }
+
+ /**
+ * @return {string}
+ * @api
+ */
+ getCseKey() {
+ return this.getProperty('cseKey');
+ }
+
+ /**
+ *
+ * @param {string} value
+ * @return {this}
+ * @api
+ */
+ setCseKey(value) {
+ return this.setProperty('cseKey', value);
+ }
+
+ /**
+ * @return {string}
+ * @api
+ */
+ getSseType() {
+ return this.getProperty('sseType');
+ }
+
+ /**
+ * @param {string} value
+ *
+ * @return {this}
+ * @api
+ */
+ setSseType(value) {
+ return this.setProperty('sseType', value);
+ }
+
+ /**
+ * @return {string}
+ * @api
+ */
+ getClient() {
+ return this.getProperty('client');
+ }
+
+ /**
+ * @param {string} value
+ *
+ * @return {this}
+ * @api
+ */
+ setClient(value) {
+ return this.setProperty('client', value);
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ isHidden() {
+ return this.getProperty('hidden');
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ getHidden() {
+ return this.getProperty('hidden');
+ }
+
+ /**
+ * @param {Boolean} value
+ *
+ * @return {this}
+ * @api
+ */
+ setHidden(value) {
+ return this.setProperty('hidden', value);
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ isTrashed() {
+ return this.getProperty('trashed');
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ getTrashed() {
+ return this.getProperty('trashed');
+ }
+
+ /**
+ * @param {Boolean} value
+ *
+ * @return {this}
+ * @api
+ */
+ setTrashed(value) {
+ return this.setProperty('trashed', value);
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ isFavorite() {
+ return this.getProperty('favorite');
+ }
+
+ /**
+ * @return {Boolean}
+ * @api
+ */
+ getFavorite() {
+ return this.getProperty('favorite');
+ }
+
+ /**
+ * @param {Boolean} value
+ *
+ * @return {this}
+ * @api
+ */
+ setFavorite(value) {
+ return this.setProperty('favorite', value);
+ }
+
+ /**
+ * @return {Date}
+ * @api
+ */
+ getEdited() {
+ return this.getProperty('edited');
+ }
+
+ /**
+ * @param {Date} value
+ *
+ * @return {this}
+ * @api
+ */
+ setEdited(value) {
+ return this.setProperty('edited', value);
+ }
+
+ /**
+ * @return {Date}
+ * @api
+ */
+ getCreated() {
+ return this.getProperty('created');
+ }
+
+ /**
+ * @param {Date} value
+ *
+ * @return {this}
+ * @api
+ */
+ setCreated(value) {
+ return this.setProperty('created', value);
+ }
+
+ /**
+ * @return {Date}
+ * @api
+ */
+ getUpdated() {
+ return this.getProperty('updated');
+ }
+
+ /**
+ * @param {Date} value
+ *
+ * @return {this}
+ * @api
+ */
+ setUpdated(value) {
+ return this.setProperty('updated', value);
+ }
+
+ /**
+ *
+ * @return {{}}
+ * @api
+ */
+ toJSON() {
+ let properties = this.getProperties();
+
+ if(properties.hasOwnProperty('created') && properties.created instanceof Date) {
+ properties.created = Math.floor(properties.created.getTime() / 1000);
+ }
+
+ if(properties.hasOwnProperty('edited') && properties.edited instanceof Date) {
+ properties.edited = Math.floor(properties.edited.getTime() / 1000);
+ }
+
+ if(properties.hasOwnProperty('updated') && properties.updated instanceof Date) {
+ properties.updated = Math.floor(properties.updated.getTime() / 1000);
+ }
+
+ return properties;
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/AbstractField.js b/src/Model/CustomField/AbstractField.js
new file mode 100644
index 0000000..d1576d7
--- /dev/null
+++ b/src/Model/CustomField/AbstractField.js
@@ -0,0 +1,95 @@
+import AbstractModel from '../AbstractModel';
+
+export default class AbstractField extends AbstractModel {
+
+ /**
+ * @param {String} value
+ */
+ set type(value) {
+ throw new Error('Type is not writeable')
+ }
+
+ /**
+ * @return {String}
+ */
+ get type() {
+ return this.getProperty('type');
+ }
+
+ /**
+ * @param {String} value
+ */
+ set label(value) {
+ this.setProperty('label', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ get label() {
+ return this.getProperty('label');
+ }
+
+ /**
+ * @param {String} value
+ */
+ set value(value) {
+ this.setProperty('value', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ get value() {
+ return this.getProperty('value');
+ }
+
+ /**
+ * @param {String} value
+ * @return {this}
+ */
+ setType(value) {
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getType() {
+ return this.getProperty('type');
+ }
+
+ /**
+ * @param {String} value
+ * @return {this}
+ */
+ setLabel(value) {
+ this.label = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getLabel() {
+ return this.getProperty('label');
+ }
+
+ /**
+ * @param {String} value
+ * @return {this}
+ */
+ setValue(value) {
+ this.value = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getValue() {
+ return this.getProperty('value');
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/DataField.js b/src/Model/CustomField/DataField.js
new file mode 100644
index 0000000..5e9ee01
--- /dev/null
+++ b/src/Model/CustomField/DataField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/DataField';
+
+export default class DataField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'data';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/DefectField.js b/src/Model/CustomField/DefectField.js
new file mode 100644
index 0000000..773f311
--- /dev/null
+++ b/src/Model/CustomField/DefectField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/TextField.json';
+
+export default class DefectField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'defect';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/EmailField.js b/src/Model/CustomField/EmailField.js
new file mode 100644
index 0000000..a2f9c35
--- /dev/null
+++ b/src/Model/CustomField/EmailField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/EmailField';
+
+export default class EmailField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'email';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/FileField.js b/src/Model/CustomField/FileField.js
new file mode 100644
index 0000000..9333742
--- /dev/null
+++ b/src/Model/CustomField/FileField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/FileField';
+
+export default class FileField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'file';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/SecretField.js b/src/Model/CustomField/SecretField.js
new file mode 100644
index 0000000..9c0e2a9
--- /dev/null
+++ b/src/Model/CustomField/SecretField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/SecretField';
+
+export default class SecretField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'secret';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/TextField.js b/src/Model/CustomField/TextField.js
new file mode 100644
index 0000000..8be3f8e
--- /dev/null
+++ b/src/Model/CustomField/TextField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/TextField';
+
+export default class TextField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'text';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/CustomField/UrlField.js b/src/Model/CustomField/UrlField.js
new file mode 100644
index 0000000..d572e5c
--- /dev/null
+++ b/src/Model/CustomField/UrlField.js
@@ -0,0 +1,14 @@
+import AbstractField from './AbstractField';
+import Properties from '../../Configuration/UrlField';
+
+export default class UrlField extends AbstractField {
+
+ /**
+ *
+ * @param {object} data
+ */
+ constructor(data) {
+ data.type = 'url';
+ super(Properties, data);
+ }
+} \ No newline at end of file
diff --git a/src/Model/Folder/EnhancedFolder.js b/src/Model/Folder/EnhancedFolder.js
new file mode 100644
index 0000000..cbc6d8f
--- /dev/null
+++ b/src/Model/Folder/EnhancedFolder.js
@@ -0,0 +1,70 @@
+import Folder from './Folder';
+
+export default class EnhancedFolder extends Folder {
+
+ /**
+ *
+ * @param {Object} [data={}]
+ * @param {BasicPasswordsClient} api
+ */
+ constructor(data = {}, api) {
+ super(data);
+ this._api = api;
+ }
+
+ /**
+ *
+ * @returns {Server}
+ */
+ getServer() {
+ return this._api.getServer();
+ }
+
+ /**
+ *
+ * @returns {Promise<FolderCollection>}
+ */
+ async fetchRevisions() {
+ if(this.getProperty('revisions') === undefined) {
+ await this._api.getFolderRepository().findById(this.getId(), 'revisions');
+ }
+
+ return this.getProperty('revisions');
+ }
+
+ /**
+ *
+ * @returns {Promise<PasswordCollection>}
+ */
+ async fetchPasswords() {
+ if(this.getProperty('passwords') === undefined) {
+ await this._api.getFolderRepository().findById(this.getId(), 'passwords');
+ }
+
+ return this.getProperty('passwords');
+ }
+
+ /**
+ *
+ * @returns {Promise<FolderCollection>}
+ */
+ async fetchFolders() {
+ if(this.getProperty('folders') === undefined) {
+ await this._api.getFolderRepository().findById(this.getId(), 'folders');
+ }
+
+ return this.getProperty('folders');
+ }
+
+ /**
+ *
+ * @returns {Promise<Folder>}
+ */
+ async fetchParent() {
+ if(this.getProperty('parent') === undefined) {
+ await this._api.getFolderRepository().findById(this.getId(), 'parent');
+ }
+
+ return this.getProperty('parent');
+ }
+} \ No newline at end of file
diff --git a/src/Model/Folder/Folder.js b/src/Model/Folder/Folder.js
new file mode 100644
index 0000000..46c4b76
--- /dev/null
+++ b/src/Model/Folder/Folder.js
@@ -0,0 +1,119 @@
+import Properties from '../../Configuration/Folder';
+import AbstractRevisionModel from '../AbstractRevisionModel';
+
+export default class Folder extends AbstractRevisionModel {
+
+ /**
+ *
+ * @param {Object} [data={}]
+ */
+ constructor(data = {}) {
+ super(Properties, data);
+ }
+
+ /**
+ * @return {String}
+ */
+ getLabel() {
+ return this.getProperty('label');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Folder}
+ */
+ setLabel(value) {
+ return this.setProperty('label', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getParentId() {
+ if(this._properties.hasOwnProperty('parent')) {
+ return this.getParent().getId();
+ }
+
+ return this.getProperty('parentId');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Folder}
+ */
+ setParentId(value) {
+ if(this._properties.hasOwnProperty('parent')) {
+ return this.setParent(null);
+ }
+
+ this.setProperty('parentId', value);
+
+ return this;
+ }
+
+ /**
+ * @return {Folder}
+ */
+ getParent() {
+ return this.getProperty('parent');
+ }
+
+ /**
+ * @param {Folder} value
+ *
+ * @return {Folder}
+ */
+ setParent(value) {
+ return this.setProperty('parent', value);
+ }
+
+ /**
+ * @return {(FolderCollection|null)}
+ */
+ getFolders() {
+ return this.getProperty('folders');
+ }
+
+ /**
+ * @param {(FolderCollection|null)} value
+ *
+ * @return {Folder}
+ */
+ setFolders(value) {
+ return this.setProperty('folders', value);
+ }
+
+ /**
+ * @return {(PasswordCollection|null)}
+ */
+ getPasswords() {
+ return this.getProperty('passwords');
+ }
+
+ /**
+ * @param {(PasswordCollection|null)} value
+ *
+ * @return {Folder}
+ */
+ setPasswords(value) {
+ return this.setProperty('passwords', value);
+ }
+
+ /**
+ * @return {(FolderCollection|null)}
+ */
+ getRevisions() {
+ return this.getProperty('revisions');
+ }
+
+ /**
+ * @param {(FolderCollection|null)} value
+ *
+ * @return {Folder}
+ */
+ setRevisions(value) {
+ return this.setProperty('revisions', value);
+ }
+} \ No newline at end of file
diff --git a/src/Model/Password/EnhancedPassword.js b/src/Model/Password/EnhancedPassword.js
new file mode 100644
index 0000000..9290910
--- /dev/null
+++ b/src/Model/Password/EnhancedPassword.js
@@ -0,0 +1,130 @@
+import Password from './Password';
+import Url from 'url-parse';
+
+export default class EnhancedPassword extends Password {
+
+ /**
+ *
+ * @param {BasicPasswordsClient} api
+ * @param {Object} [data={}]
+ */
+ constructor(data = {}, api) {
+ super(data);
+ this._api = api;
+ }
+
+ /**
+ *
+ * @returns {Server}
+ */
+ getServer() {
+ return this._api.getServer();
+ }
+
+ /**
+ *
+ * @param {Number} [size=32]
+ * @param {Boolean} [pathOnly=false]
+ * @return {String}
+ */
+ getFaviconUrl(size = 32, pathOnly = false) {
+ let host = 'default';
+
+ if(this.getUrl()) {
+ let url = Url(this.getUrl());
+
+ if(url.host.length !== 0) {
+ host = url.host;
+ }
+ }
+
+ if(pathOnly) return `1.0/service/favicon/${host}/${size}`;
+
+ return `${this.getServer().getApiUrl()}1.0/service/favicon/${host}/${size}`;
+ }
+
+ /**
+ *
+ * @param size
+ * @returns {Promise<Blob>}
+ */
+ async getFavicon(size = 32) {
+ let path = this.getFaviconUrl(size, true),
+ response = await this._api.getRequest().setPath(path).setResponseType('image/png').send();
+
+ return response.getData();
+ }
+
+ /**
+ *
+ * @param {(String|Number)} width
+ * @param {(String|Number)} height
+ * @param {String} view
+ * @param {Boolean} [pathOnly=false]
+ * @return {String}
+ */
+ getPreviewUrl(width = 640, height = '360...', view = 'desktop', pathOnly = false) {
+ let host = 'default';
+
+ if(this.getUrl()) {
+ let url = Url(this.getUrl());
+
+ if(url.host.length !== 0) {
+ host = url.host;
+ }
+ }
+
+ if(pathOnly) return `1.0/service/preview/${host}/${view}/${width}/${height}`;
+
+ return `${this.getServer().getApiUrl()}1.0/service/preview/${host}/${view}/${width}/${height}`;
+ }
+
+ /**
+ *
+ * @param {(String|Number)} width
+ * @param {(String|Number)} height
+ * @param {String} view
+ * @returns {Promise<Blob>}
+ */
+ async getPreview(width = 640, height = '360...', view) {
+ let path = this.getPreviewUrl(width, height, view, true),
+ response = await this._api.getRequest().setPath(path).setResponseType('image/jpeg').send();
+
+ return response.getData();
+ }
+
+ /**
+ *
+ * @returns {Promise<PasswordCollection>}
+ */
+ async fetchRevisions() {
+ }
+
+ /**
+ *
+ * @returns {Promise<Share>}
+ */
+ async fetchShare() {
+ }
+
+ /**
+ *
+ * @returns {Promise<ShareCollection>}
+ */
+ async fetchShares() {
+ }
+
+ /**
+ *
+ * @returns {Promise<TagCollection>}
+ */
+ async fetchTags() {
+ }
+
+ /**
+ *
+ * @returns {Promise<FolderCollection>}
+ */
+ async fetchFolder() {
+ }
+} \ No newline at end of file
diff --git a/src/Model/Password/Password.js b/src/Model/Password/Password.js
new file mode 100644
index 0000000..dd69b68
--- /dev/null
+++ b/src/Model/Password/Password.js
@@ -0,0 +1,232 @@
+import Properties from '../../Configuration/Password';
+import AbstractRevisionModel from '../AbstractRevisionModel';
+
+export default class Password extends AbstractRevisionModel {
+
+ /**
+ *
+ * @param {Object} [data={}]
+ */
+ constructor(data = {}) {
+ super(Properties, data);
+ }
+
+ /**
+ * @return {String}
+ */
+ getLabel() {
+ return this.getProperty('label');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setLabel(value) {
+ return this.setProperty('label', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getUserName() {
+ return this.getProperty('username');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setUserName(value) {
+ return this.setProperty('username', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getPassword() {
+ return this.getProperty('password');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setPassword(value) {
+ return this.setProperty('password', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getUrl() {
+ return this.getProperty('url');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setUrl(value) {
+ return this.setProperty('url', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getNotes() {
+ return this.getProperty('notes');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setNotes(value) {
+ return this.setProperty('notes', value);
+ }
+
+ /**
+ * @return {CustomFieldCollection}
+ */
+ getCustomFields() {
+ return this.getProperty('customFields');
+ }
+
+ /**
+ * @param {CustomFieldCollection} value
+ * @return {AbstractModel}
+ */
+ setCustomFields(value) {
+ return this.setProperty('customFields', value);
+ }
+
+ /**
+ * @return {Number}
+ */
+ getStatus() {
+ return this.getProperty('status');
+ }
+
+ /**
+ * @param {Number} value
+ *
+ * @return {Password}
+ */
+ setStatus(value) {
+ return this.setProperty('status', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getStatusCode() {
+ return this.getProperty('statusCode');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setStatusCode(value) {
+ return this.setProperty('statusCode', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getHash() {
+ return this.getProperty('hash');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setHash(value) {
+ return this.setProperty('hash', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getFolder() {
+ return this.getProperty('folder');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Password}
+ */
+ setFolder(value) {
+ return this.setProperty('folder', value);
+ }
+
+ /**
+ * @return {(string|null)}
+ */
+ getShare() {
+ return this.getProperty('share');
+ }
+
+ /**
+ * @param {(string|null)} value
+ *
+ * @return {Password}
+ */
+ setShare(value) {
+ return this.setProperty('share', value);
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ isShared() {
+ return this.getProperty('shared');
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ getShared() {
+ return this.getProperty('shared');
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ setShared(value) {
+ return this.setProperty('shared', value);
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ isEditable() {
+ return this.getProperty('editable');
+ }
+
+ /**
+ * @return {Boolean}
+ */
+ getEditable() {
+ return this.getProperty('editable');
+ }
+
+ /**
+ * @param {Boolean} value
+ *
+ * @return {Password}
+ */
+ setEditable(value) {
+ return this.setProperty('editable', value);
+ }
+} \ No newline at end of file
diff --git a/src/Model/Server/Server.js b/src/Model/Server/Server.js
new file mode 100644
index 0000000..6c36ba3
--- /dev/null
+++ b/src/Model/Server/Server.js
@@ -0,0 +1,80 @@
+import ConfigruationError from '../../Exception/ConfigruationError';
+import Properties from '../../Configuration/Server';
+import AbstractModel from './../AbstractModel';
+import ObjectMerger from '../../Utility/ObjectMerger';
+
+export default class Server extends AbstractModel {
+
+ /**
+ *
+ * @param {Object} data
+ * @param {(Object|null)} [properties=null]
+ */
+ constructor(data = {}, properties = null) {
+ if(!data.hasOwnProperty('baseUrl') || data.baseUrl.substr(0, 5) !== 'https') {
+ throw new ConfigruationError('Base URL missing or invalid');
+ }
+
+ if(properties !== null) ObjectMerger.merge(Properties, properties);
+ super(Properties, data);
+ }
+
+ /**
+ * @return {String}
+ */
+ getBaseUrl() {
+ return this.getProperty('baseUrl');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Server}
+ */
+ setBaseUrl(value) {
+ if(value.substr(0, 5) !== 'https') {
+ throw new ConfigruationError('Base URL missing or invalid');
+ }
+
+ return this.setProperty('baseUrl', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getUser() {
+ return this.getProperty('user');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Server}
+ */
+ setUser(value) {
+ return this.setProperty('user', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getToken() {
+ return this.getProperty('token');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Server}
+ */
+ setToken(value) {
+ return this.setProperty('token', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getApiUrl() {
+ return `${this.getBaseUrl()}index.php/apps/passwords/api/`;
+ }
+} \ No newline at end of file
diff --git a/src/Model/Session/Session.js b/src/Model/Session/Session.js
new file mode 100644
index 0000000..f01f398
--- /dev/null
+++ b/src/Model/Session/Session.js
@@ -0,0 +1,84 @@
+export default class Session {
+
+ constructor(user = null, token = null, id = null, authorized = false) {
+ this._user = user;
+ this._token = token;
+ this._id = id;
+ this._authorized = authorized;
+ }
+
+ /**
+ * @return {String}
+ */
+ getId() {
+ return this._id;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Session}
+ */
+ setId(value) {
+ this._id = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getUser() {
+ return this._user;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Session}
+ */
+ setUser(value) {
+ this._user = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getToken() {
+ return this._token;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Session}
+ */
+ setToken(value) {
+ this._token = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @return {Boolean}
+ */
+ getAuthorized() {
+ return this._authorized;
+ }
+
+ /**
+ * @param {Boolean} value
+ *
+ * @return {Session}
+ */
+ setAuthorized(value) {
+ this._authorized = value;
+
+ return this;
+ }
+
+
+} \ No newline at end of file
diff --git a/src/Model/Setting/Setting.js b/src/Model/Setting/Setting.js
new file mode 100644
index 0000000..e790f89
--- /dev/null
+++ b/src/Model/Setting/Setting.js
@@ -0,0 +1,179 @@
+import InvalidScopeError from '../../Exception/InvalidScopeError';
+
+export default class Setting {
+
+ /**
+ * @return {String}
+ */
+ static get SCOPE_SERVER() {
+ return 'server';
+ }
+
+ /**
+ * @return {String}
+ */
+ static get SCOPE_USER() {
+ return 'user';
+ }
+
+ /**
+ * @return {String}
+ */
+ static get SCOPE_CLIENT() {
+ return 'client';
+ }
+
+ /**
+ * @return {String[]}
+ */
+ static get SCOPES() {
+ return [
+ this.SCOPE_USER,
+ this.SCOPE_SERVER,
+ this.SCOPE_CLIENT
+ ];
+ }
+
+ get id() {
+ return `${this.scope}.${this.value}`
+ }
+
+ /**
+ * @return {String}
+ */
+ get name() {
+ return this._name;
+ }
+
+ /**
+ * @param {String} value
+ */
+ set name(value) {
+ this._name = value;
+ }
+
+ /**
+ * @return {String}
+ */
+ get value() {
+ return this._value;
+ }
+
+ /**
+ * @param {String} value
+ */
+ set value(value) {
+ this._value = value;
+ }
+
+ /**
+ * @return {String}
+ */
+ get scope() {
+ return this._scope;
+ }
+
+ /**
+ * @param {String} value
+ */
+ set scope(value) {
+ this._checkScope(value);
+
+ this._scope = value;
+ }
+
+ /**
+ * @param {String} name
+ * @param {String} value
+ * @param {String} scope
+ */
+ constructor(name, value, scope = 'client') {
+ this._checkScope(scope);
+
+ this._name = name;
+ this._value = value;
+ this._scope = scope;
+ }
+
+ /**
+ * @return {String}
+ */
+ getId() {
+ return `${this.getScope()}.${this.getName()}`
+ }
+
+ /**
+ * @return {String}
+ */
+ getName() {
+ return this._name;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Setting}
+ */
+ setName(value) {
+ this.name = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getValue() {
+ return this._value;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Setting}
+ */
+ setValue(value) {
+ this.value = value;
+
+ return this;
+ }
+
+ /**
+ * @return {String}
+ */
+ getScope() {
+ return this._scope;
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Setting}
+ */
+ setScope(value) {
+ this.scope = value;
+
+ return this;
+ }
+
+ /**
+ * @param {String} scope
+ * @private
+ */
+ _checkScope(scope) {
+ if(Setting.SCOPES.indexOf(scope) === -1) {
+ throw new InvalidScopeError(scope);
+ }
+ }
+
+ /**
+ * @return {{scope: String, name: String, value: String}}
+ */
+ toJSON() {
+ return {
+ scope: this._scope,
+ name : this._name,
+ value: this._value
+ };
+ }
+} \ No newline at end of file
diff --git a/src/Model/Tag/EnhancedTag.js b/src/Model/Tag/EnhancedTag.js
new file mode 100644
index 0000000..5d43371
--- /dev/null
+++ b/src/Model/Tag/EnhancedTag.js
@@ -0,0 +1,38 @@
+import Tag from './Tag';
+
+export default class EnhancedTag extends Tag {
+
+ /**
+ *
+ * @param {Object} [data={}]
+ * @param {BasicPasswordsClient} api
+ */
+ constructor(data = {}, api) {
+ super(data);
+ this._api = api;
+ }
+
+ /**
+ *
+ * @returns {Server}
+ */
+ getServer() {
+ return this._api.getServer();
+ }
+
+ /**
+ *
+ * @returns {Promise<TagCollection>}
+ */
+ async fetchRevisions() {
+
+ }
+
+ /**
+ *
+ * @returns {Promise<PasswordCollection>}
+ */
+ async fetchPasswords() {
+
+ }
+} \ No newline at end of file
diff --git a/src/Model/Tag/Tag.js b/src/Model/Tag/Tag.js
new file mode 100644
index 0000000..1cff8b7
--- /dev/null
+++ b/src/Model/Tag/Tag.js
@@ -0,0 +1,45 @@
+import Properties from '../../Configuration/Tag';
+import AbstractRevisionModel from '../AbstractRevisionModel';
+
+export default class Tag extends AbstractRevisionModel {
+
+ /**
+ *
+ * @param {Object} [data={}]
+ */
+ constructor(data = {}) {
+ super(Properties, data);
+ }
+
+ /**
+ * @return {String}
+ */
+ getLabel() {
+ return this.getProperty('label');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Tag}
+ */
+ setLabel(value) {
+ return this.setProperty('label', value);
+ }
+
+ /**
+ * @return {String}
+ */
+ getColor() {
+ return this.getProperty('color');
+ }
+
+ /**
+ * @param {String} value
+ *
+ * @return {Tag}
+ */
+ setColor(value) {
+ return this.setProperty('color', value);
+ }
+} \ No newline at end of file
diff --git a/src/Network/ApiRequest.js b/src/Network/ApiRequest.js
new file mode 100644
index 0000000..1c0dd1a
--- /dev/null
+++ b/src/Network/ApiRequest.js
@@ -0,0 +1,274 @@
+import ApiResponse from './ApiResponse';
+
+export default class ApiRequest {
+
+ /**
+ *
+ * @param {BasicPasswordsClient} api
+ * @param {String} [url=null]
+ * @param {Session} [session=null]
+ */
+ constructor(api, url = null, session = null) {
+ this._api = api;
+ this._url = url;
+ this._path = null;
+ this._data = null;
+ this._method = null;
+ this._userAgent = null;
+ this._session = session;
+ this._responseType = 'application/json';
+ }
+
+ /**
+ *
+ * @returns {(String|null)}
+ */
+ getUrl() {
+ return this._url;
+ }
+
+ /**
+ *
+ * @param {Session} value
+ * @returns {ApiRequest}
+ */
+ setUrl(value) {
+ this._url = value;
+
+ return this;
+ }
+
+ /**
+ * @returns {Session}
+ */
+ getSession() {
+ return this._session;
+ }
+
+ /**
+ *
+ * @param {Session} value
+ * @returns {ApiRequest}
+ */
+ setSession(value) {
+ this._session = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @return {ApiRequest}
+ */
+ setPath(value) {
+ this._path = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {Object} value
+ * @return {ApiRequest}
+ */
+ setData(value) {
+ this._data = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @return {ApiRequest}
+ */
+ setMethod(value) {
+ this._method = value.toUpperCase();
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @returns {ApiRequest}
+ */
+ setResponseType(value) {
+ this._responseType = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @return {ApiRequest}
+ */
+ setUserAgent(value) {
+ this._userAgent = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @returns {Promise<ApiResponse>}
+ */
+ async send() {
+ let options = this._getRequestOptions();
+ let httpResponse = await this._executeRequest(this._url + this._path, options);
+ let contentType = httpResponse.headers.get('content-type');
+
+ let response = new ApiResponse()
+ .setContentType(contentType)
+ .setHeaders(httpResponse.headers)
+ .setHttpStatus(httpResponse.status)
+ .setHttpResponse(httpResponse);
+
+ this._session.setId(httpResponse.headers.get('x-api-session'));
+
+ if(this._responseType !== null && contentType && contentType.indexOf(this._responseType) === -1) {
+ let error = this._api.getClass('exception.contenttype', this._responseType, contentType, httpResponse);
+ this._api.emit('request.error', error);
+ throw error;
+ } else if(contentType && contentType.indexOf('application/json') !== -1) {
+ await this._processJsonResponse(httpResponse, response);
+ } else {
+ await this._processBinaryResponse(httpResponse, response);
+ }
+
+ this._api.emit('request.after', response);
+
+ return response;
+ }
+
+ /**
+ *
+ * @return {{redirect: string, headers: Headers, method: string, credentials: string}}
+ * @private
+ */
+ _getRequestOptions() {
+ let headers = this._getRequestHeaders();
+ let method = this._method === null ? 'GET':this._method;
+ let options = {method, headers, credentials: 'omit', redirect: 'error'};
+ if(this._data !== null) {
+ options.body = JSON.stringify(this._data);
+ if(method === 'GET') options.method = 'POST';
+ }
+
+ return options;
+ }
+
+ /**
+ *
+ * @return {Headers}
+ * @private
+ */
+ _getRequestHeaders() {
+ let headers = new Headers();
+
+ if(this._session.getUser() !== null) {
+ headers.append('authorization', `Basic ${btoa(`${this._session.getUser()}:${this._session.getToken()}`)}`);
+ } else if(this._session.getToken() !== null) {
+ headers.append('authorization', `Bearer ${btoa(this._session.getToken())}`);
+ }
+
+ headers.append('accept', this._responseType);
+
+ if(this._data !== null) {
+ headers.append('content-type', 'application/json');
+ }
+
+ if(this._userAgent !== null) {
+ headers.append('user-agent', this._userAgent);
+ }
+
+ if(this._session.getId() !== null) {
+ headers.append('x-api-session', this._session.getId());
+ }
+
+ return headers;
+ }
+
+ /**
+ *
+ * @param {String} url
+ * @param {Object} options
+ * @returns {Promise<Response>}
+ * @private
+ */
+ async _executeRequest(url, options) {
+ try {
+ let request = new Request(url, options);
+ this._api.emit('request.before', request);
+
+ return await fetch(request);
+ } catch(e) {
+ this._api.emit('request.error', e);
+ throw e;
+ }
+ }
+
+ /**
+ *
+ * @param {Response} httpResponse
+ * @param {ApiResponse} response
+ * @private
+ */
+ async _processJsonResponse(httpResponse, response) {
+ if(!httpResponse.ok) {
+ let error = this._getHttpError(httpResponse);
+ this._api.emit('request.error', error);
+ throw error;
+ }
+
+ try {
+ let json = await httpResponse.json();
+ response.setData(json);
+ } catch(e) {
+ let error = this._api.getClass('exception.decoding', httpResponse, e);
+ this._api.emit('request.decoding.error', error);
+ throw error;
+ }
+ }
+
+ /**
+ *
+ * @param {Response} httpResponse
+ * @param {ApiResponse} response
+ * @private
+ */
+ async _processBinaryResponse(httpResponse, response) {
+ if(!httpResponse.ok) {
+ let error = this._getHttpError(httpResponse);
+ this._api.emit('request.error', error);
+ throw error;
+ }
+
+ try {
+ let blob = await httpResponse.blob();
+ response.setData(blob);
+ } catch(e) {
+ let error = this._api.getClass('exception.decoding', httpResponse, e);
+ this._api.emit('request.decoding.error', error);
+ throw error;
+ }
+ }
+
+ /**
+ *
+ * @param {Response} response
+ * @private
+ */
+ _getHttpError(response) {
+ if([400, 401, 403, 404, 405, 429, 500, 502, 503, 504].indexOf(response.status) !== -1) {
+ return this._api.getClass(`exception.${response.status}`, response);
+ }
+ if(response.status > 99) {
+ return this._api.getClass('exception.http', response);
+ }
+
+ return this._api.getClass('exception.network', response);
+ }
+} \ No newline at end of file
diff --git a/src/Network/ApiResponse.js b/src/Network/ApiResponse.js
new file mode 100644
index 0000000..dffb55b
--- /dev/null
+++ b/src/Network/ApiResponse.js
@@ -0,0 +1,4 @@
+import HttpResponse from './HttpResponse';
+
+export default class ApiResponse extends HttpResponse {
+} \ No newline at end of file
diff --git a/src/Network/HttpRequest.js b/src/Network/HttpRequest.js
new file mode 100644
index 0000000..ed5f88d
--- /dev/null
+++ b/src/Network/HttpRequest.js
@@ -0,0 +1,194 @@
+import ResponseDecodingError from '../Exception/ResponseDecodingError';
+import NetworkError from '../Exception/NetworkError';
+import HttpError from '../Exception/Http/HttpError';
+import ResponseContentTypeError from '../Exception/ResponseContentTypeError';
+import HttpResponse from './HttpResponse';
+
+export default class HttpRequest {
+
+ /**
+ *
+ * @param {String} [url=null]
+ */
+ constructor(url = null) {
+ this._url = url;
+ this._data = null;
+ this._userAgent = null;
+ this._responseType = 'application/json';
+ }
+
+ /**
+ *
+ * @returns {(String|null)}
+ */
+ getUrl() {
+ return this._url;
+ }
+
+ /**
+ *
+ * @param {Session} value
+ * @returns {HttpRequest}
+ */
+ setUrl(value) {
+ this._url = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {Object} value
+ * @return {HttpRequest}
+ */
+ setData(value) {
+ this._data = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @return {HttpRequest}
+ */
+ setUserAgent(value) {
+ this._userAgent = value;
+
+ return this;
+ }
+
+ /**
+ *
+ * @returns {Promise<HttpResponse>}
+ */
+ async send() {
+ let options = this._getRequestOptions();
+ let httpResponse = await this._executeRequest(this._url, options);
+ let expectedContentType = options.headers.get('content-type');
+ let contentType = httpResponse.headers.get('content-type');
+
+ let response = new HttpResponse()
+ .setContentType(contentType)
+ .setHeaders(httpResponse.headers)
+ .setHttpStatus(httpResponse.status)
+ .setHttpResponse(httpResponse);
+
+ if(expectedContentType !== null && contentType && contentType.indexOf(expectedContentType) === -1) {
+ throw new ResponseContentTypeError(expectedContentType, contentType, httpResponse);
+ } else if(contentType && contentType.indexOf('application/json') !== -1) {
+ await this._processJsonResponse(httpResponse, response);
+ } else {
+ await this._processBinaryResponse(httpResponse, response);
+ }
+
+ return response;
+ }
+
+ /**
+ *
+ * @return {{redirect: string, headers: Headers, method: string, credentials: string}}
+ * @private
+ */
+ _getRequestOptions() {
+ let headers = this._getRequestHeaders();
+ let method = 'GET';
+ let options = {method, headers, credentials: 'omit', redirect: 'error'};
+ if(this._data !== null) {
+ options.body = JSON.stringify(this._data);
+ method = 'POST';
+ }
+ options.method = method;
+
+ return options;
+ }
+
+ /**
+ *
+ * @return {Headers}
+ * @private
+ */
+ _getRequestHeaders() {
+ let headers = new Headers();
+
+ headers.append('accept', this._responseType);
+
+ if(this._data !== null) {
+ headers.append('content-type', 'application/json');
+ }
+
+ if(this._userAgent !== null) {
+ headers.append('user-agent', this._userAgent);
+ }
+
+ return headers;
+ }
+
+ /**
+ *
+ * @param {String} url
+ * @param {Object} options
+ * @returns {Promise<Response>}
+ * @private
+ */
+ async _executeRequest(url, options) {
+ try {
+ let request = new Request(url, options);
+
+ return await fetch(request);
+ } catch(e) {
+ throw e;
+ }
+ }
+
+ /**
+ *
+ * @param {Response} httpResponse
+ * @param {HttpResponse} response
+ * @private
+ */
+ async _processJsonResponse(httpResponse, response) {
+ if(!httpResponse.ok) {
+ throw this._getHttpError(httpResponse);
+ }
+
+ try {
+ let json = await httpResponse.json();
+ response.setData(json);
+ } catch(e) {
+ throw new ResponseDecodingError(response, e);
+ }
+ }
+
+ /**
+ *
+ * @param {Response} httpResponse
+ * @param {HttpResponse} response
+ * @private
+ */
+ async _processBinaryResponse(httpResponse, response) {
+ if(!httpResponse.ok) {
+ throw this._getHttpError(httpResponse);
+ }
+
+ try {
+ let blob = await httpResponse.blob();
+ response.setData(blob);
+ } catch(e) {
+ throw new ResponseDecodingError(response, e);
+ }
+ }
+
+ /**
+ *
+ * @param {Response} response
+ * @private
+ */
+ _getHttpError(response) {
+ if(response.status > 99) {
+ return new HttpError(response);
+ }
+
+ return new NetworkError(response);
+ }
+} \ No newline at end of file
diff --git a/src/Network/HttpResponse.js b/src/Network/HttpResponse.js
new file mode 100644
index 0000000..7758045
--- /dev/null
+++ b/src/Network/HttpResponse.js
@@ -0,0 +1,60 @@
+export default class HttpResponse {
+
+ constructor() {
+ this._data = null;
+ this._headers = [];
+ this._contentType = 'text/plain';
+ this._httpStatus = 0;
+ this._response = {};
+ }
+
+ getData() {
+ return this._data;
+ }
+
+ setData(value) {
+ this._data = value;
+
+ return this;
+ }
+
+ getHeaders() {
+ return this._headers;
+ }
+
+ setHeaders(value) {
+ this._headers = value;
+
+ return this;
+ }
+
+ getContentType() {
+ return this._contentType;
+ }
+
+ setContentType(value) {
+ this._contentType = value;
+
+ return this;
+ }
+
+ getHttpStatus() {
+ return this._httpStatus;
+ }
+
+ setHttpStatus(value) {
+ this._httpStatus = value;
+
+ return this;
+ }
+
+ getHttpResponse() {
+ return this._response;
+ }
+
+ setHttpResponse(value) {
+ this._response = value;
+ return this;
+ }
+
+} \ No newline at end of file
diff --git a/src/PassLink/Action/Connect.js b/src/PassLink/Action/Connect.js
new file mode 100644
index 0000000..9b27e77
--- /dev/null
+++ b/src/PassLink/Action/Connect.js
@@ -0,0 +1,143 @@
+import PassLinkAction from './PassLinkAction';
+import HttpRequest from '../../Network/HttpRequest';
+
+export default class Connect extends PassLinkAction {
+
+ /**
+ *
+ * @param {Object} parameters
+ */
+ constructor(parameters) {
+ super(parameters);
+ this._codes = null;
+ this._clientLabel = null;
+ this._theme = null;
+ this._promise = null;
+ }
+
+ /**
+ * Get the suggested name of the client
+ *
+ * @return {(null|String)}
+ */
+ getClientLabel() {
+ return this._clientLabel;
+ }
+
+ /**
+ * Set the suggested name of the client
+ *
+ * @param {String} value
+ * @return {Connect}
+ */
+ setClientLabel(value) {
+ this._clientLabel = value;
+
+ return this;
+ }
+
+ /**
+ * Get the theme from the link
+ *
+ * @return {Promise<null|object>|null}
+ */
+ getTheme() {
+ if(this._theme !== null) return this._theme;
+
+ return this._decodeTheme();
+ }
+
+ /**
+ *
+ * @return {[]}
+ */
+ getCodes() {
+ if(this._codes !== null) return this._codes;
+
+ let codes = [],
+ spec = new RegExp('^(?=\\S*[a-z])(?=\\S*[A-Z])(?=\\S*[\\d])\\S*$');
+
+ while(codes.length < 4) {
+ let code = Array(4)
+ .fill('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
+ .map(x => x[Math.floor(crypto.getRandomValues(new Uint32Array(1))[0] / (0xffffffff + 1) * x.length)])
+ .join('');
+
+ if(spec.test(code)) codes.push(code);
+ }
+
+ this._codes = codes;
+ return codes;
+ }
+
+ /**
+ * Apply for the registration
+ *
+ * @return {Promise<void>}
+ */
+ apply() {
+ if(this._promise === null) {
+ this._promise = this._sendRequest();
+ }
+
+ return this._promise;
+ }
+
+ /**
+ *
+ * @return {Promise<void>}
+ * @private
+ */
+ async _sendRequest() {
+ let url = `${this._parameters.baseUrl}index.php/apps/passwords/link/connect/apply`,
+ request = new HttpRequest(url);
+
+ let data = {
+ id : this._parameters.id,
+ codes: this.getCodes()
+ };
+
+ if(this._clientLabel !== null) data.label = this._clientLabel;
+
+ let response = await request.setData(data).send();
+
+ return response.getData();
+ }
+
+ /**
+ *
+ * @private
+ */
+ async _decodeTheme() {
+ if(!this._parameters.hasOwnProperty('theme')) return null;
+
+ let pako = await import(/* webpackChunkName: "pako" */ 'pako'),
+ base64 = this._parameters.theme,
+ zipped = atob(base64),
+ json = pako.inflate(zipped, {to: 'string'}),
+ theme = JSON.parse(json);
+
+ if(theme.hasOwnProperty('color')) {
+ theme['color.primary'] = theme.color;
+ delete theme.color;
+ }
+ if(theme.hasOwnProperty('txtColor')) {
+ theme['color.text'] = theme.txtColor;
+ delete theme.txtColor;
+ }
+ if(theme.hasOwnProperty('bgColor')) {
+ theme['color.background'] = theme.bgColor;
+ delete theme.bgColor;
+ }
+ if(theme.hasOwnProperty('background') && theme.background.substr(0, 8) !== 'https://') {
+ theme.background = this._parameters.baseUrl + theme.background;
+ }
+ if(theme.hasOwnProperty('logo')) {
+ theme.logo = this._parameters.baseUrl + theme.logo;
+ }
+
+ this._theme = theme;
+
+ return theme;
+ }
+} \ No newline at end of file
diff --git a/src/PassLink/Action/PassLinkAction.js b/src/PassLink/Action/PassLinkAction.js
new file mode 100644
index 0000000..731544d
--- /dev/null
+++ b/src/PassLink/Action/PassLinkAction.js
@@ -0,0 +1,27 @@
+export default class PassLinkAction {
+ constructor(parameters) {
+ this._parameters = parameters;
+ }
+
+ /**
+ *
+ * @return {Object}
+ */
+ getParameters() {
+ return this._parameters;
+ }
+
+ /**
+ *
+ * @param {String} name
+ * @return {*}
+ */
+ getParameter(name) {
+ if(this._parameters.hasOwnProperty(name)) {
+ return this._parameters[name];
+ }
+
+ return null;
+ }
+
+} \ No newline at end of file
diff --git a/src/PassLink/PassLink.js b/src/PassLink/PassLink.js
new file mode 100644
index 0000000..db89796
--- /dev/null
+++ b/src/PassLink/PassLink.js
@@ -0,0 +1,42 @@
+import InvalidLink from '../Exception/PassLink/InvalidLink';
+import UnknownAction from '../Exception/PassLink/UnknownAction';
+import Connect from './Action/Connect';
+
+class PassLink {
+
+ /**
+ *
+ * @param {String} link
+ * @return {{server: String, action: String, parameters: {}}}
+ */
+ analyzeLink(link) {
+ let url = new URL(link);
+
+ if(['ext+passlink:', 'web+passlink:', 'passlink:'].indexOf(url.protocol) === -1 || url.pathname.indexOf('/do/') === -1) {
+ throw new InvalidLink();
+ }
+ let [server, action] = url.pathname.split('do/');
+
+ let parameters = {};
+ for(let key of url.searchParams.keys()) {
+ parameters[key] = url.searchParams.get(key);
+ }
+ parameters.baseUrl = `https://${server}`;
+
+ return {server, action, parameters}
+ }
+
+ /**
+ *
+ * @param {String} action
+ * @param {Object} parameters
+ * @return {PassLinkAction}
+ */
+ getAction(action, parameters) {
+ if(action === 'connect') return new Connect(parameters);
+
+ throw new UnknownAction(action);
+ }
+}
+
+export default new PassLink(); \ No newline at end of file
diff --git a/src/Repositories/AbstractRepository.js b/src/Repositories/AbstractRepository.js
new file mode 100644
index 0000000..b947299
--- /dev/null
+++ b/src/Repositories/AbstractRepository.js
@@ -0,0 +1,259 @@
+export default class AbstractRepository {
+
+ /**
+ * @return {String[]}
+ * @constructor
+ */
+ get AVAILABLE_DETAIL_LEVELS() {
+ return ['id', 'model'];
+ }
+
+ /**
+ * @return {String[]}
+ * @constructor
+ */
+ get DEFAULT_DETAIL_LEVEL() {
+ return ['model'];
+ }
+
+ get TYPE() {
+ return 'abstract';
+ }
+
+ /**
+ *
+ * @param {BasicPasswordsClient} api
+ */
+ constructor(api) {
+ this._api = api;
+ /** @type {ModelService} **/
+ this._modelService = api.getInstance('service.model');
+ /** @type {AbstractConverter} **/
+ this._converter = api.getInstance(`converter.${this.TYPE}`);
+ }
+
+ /**
+ *
+ * @deprecated
+ * @return {AbstractRepository}
+ */
+ clearCache() {
+ console.trace('AbstractRepository.clearCache() is deprecated');
+
+ return this;
+ }
+
+ /**
+ *
+ * @param {AbstractRevisionModel} model
+ * @returns {Promise<AbstractRevisionModel>}
+ */
+ async create(model) {
+ if(typeof model.getId() === 'string') {
+ // @TODO: Custom error here
+ throw new Error('Can not create object with id');
+ }
+
+ let data = await this._converter.toApiObject(model),
+ request = this._api.getRequest()
+ .setPath(`1.0/${this.TYPE}/create`)
+ .setData(data);
+
+ try {
+ let response = await request.send();
+ model.setId(response.getData().id);
+ model.setRevision(response.getData().revision);
+ model.setCreated(new Date());
+ model.setUpdated(new Date());
+ this._modelService.addModel(this.TYPE, model);
+ await this.findById(model.getId());
+ } catch(e) {
+ console.error(e);
+ throw e;
+ }
+
+ return model;
+ }
+
+ /**
+ *
+ * @param {AbstractRevisionModel} model
+ * @returns {Promise<AbstractRevisionModel>}
+ */
+ async update(model) {
+ if(typeof model.getId() !== 'string') {
+ // @TODO: Custom error here
+ throw new Error('Can not update object without id');
+ }
+
+ let data = await this._converter.toApiObject(model),
+ request = this._api.getRequest()
+ .setMethod('PATCH')
+ .setPath(`1.0/${this.TYPE}/update`)
+ .setData(data);
+
+ try {
+ let response = await request.send();
+ model.setRevision(response.getData().revision);
+ model.setUpdated(new Date());
+ this.findById(model.getId());
+ } catch(e) {
+ console.error(e);
+ throw e;
+ }
+
+ return model;
+ }
+
+ /**
+ *
+ * @param {AbstractRevisionModel} model
+ * @returns {Promise<AbstractRevisionModel>}
+ */
+ async delete(model) {
+ let request = this._api.getRequest()
+ .setPath(`1.0/${this.TYPE}/delete`)
+ .setData({id: model.getId(), revision: model.getRevision()});
+
+ try {
+ let response = await request.send();
+ model.setRevision(response.getData().revision);
+ model.setUpdated(new Date());
+
+ if(!model.isTrashed()) {
+ model.setTrashed(true);
+ }
+ } catch(e) {
+ console.error(e);
+ throw e;
+ }
+
+ return model;
+ }
+
+ /**
+ *
+ * @param {AbstractRevisionModel} model
+ * @returns {Promise<AbstractRevisionModel>}
+ */
+ async restore(model) {
+ if(!model.getTrashed()) return model;
+
+ let request = this._api.getRequest()
+ .setPath(`1.0/${this.TYPE}/restore`)
+ .setData({id: model.getId(), revision: model.getRevision()});
+
+ try {
+ let response = await request.send();
+ model.setRevision(response.getData().revision);
+ model.setUpdated(new Date());
+ model.setTrashed(false);
+ } catch(e) {
+ console.error(e);
+ throw e;
+ }
+
+ return model;
+ }
+
+ /**
+ *
+ * @param {String} id
+ * @param {(String|String[]|null)} detailLevel
+ * @returns {Promise<AbstractRevisionModel>}
+ */
+ async findById(id, detailLevel = null) {
+ detailLevel = this._getDetailLevel(detailLevel);
+
+ let details = detailLevel.join('+'),
+ request = this._api.getRequest()
+ .setPath(`1.0/${this.TYPE}/show`)
+ .setData({id, details}),
+ response = await request.send();
+
+ return await this._dataToModel(response.getData(), detailLevel);
+ }
+
+ /**
+ * @api
+ * @param {(String|String[]|null)} detailLevel
+ * @returns {Promise<AbstractRevisionModel[]>}
+ */
+ async findAll(detailLevel = null) {
+ detailLevel = this._getDetailLevel(detailLevel);
+ let details = detailLevel.join('+'),
+ request = this._api.getRequest()
+ .setData({details})
+ .setPath(`1.0/${this.TYPE}/list`),
+ response = await request.send(),
+ data = response.getData(),
+ models = await this._dataToModels(data, detailLevel);
+
+ return this._api.getClass(`collection.${this.TYPE}`, models);
+ }
+
+ /**
+ *
+ * @param {Object[]} data
+ * @param {(String[])} detailLevel
+ * @return {Promise<AbstractRevisionModel[]>}
+ * @private
+ */
+ _dataToModels(data, detailLevel) {
+ return new Promise(async (resolve, reject) => {
+ let promises = [],
+ models = [];
+
+ for(let element of data) {
+ promises.push(
+ new Promise(
+ (resolve) => {
+ this._dataToModel(element, detailLevel)
+ .then((model) => {
+ models.push(model);
+ resolve();
+ })
+ .catch(reject);
+ }
+ )
+ );
+ }
+
+ await Promise.all(promises);
+
+ resolve(models);
+ });
+ }
+
+ /**
+ *
+ * @param {Object} data
+ * @param {String[]} detailLevel
+ * @returns {Promise<AbstractRevisionModel>}
+ * @private
+ */
+ async _dataToModel(data, detailLevel) {
+ return await this._modelService.makeFromApiData(this.TYPE, data, detailLevel);
+ }
+
+ /**
+ * @param {(String[]|String|null)} detailLevel
+ * @return {String[]}
+ * @private
+ */
+ _getDetailLevel(detailLevel) {
+ if(typeof detailLevel === 'string') {
+ detailLevel = detailLevel.trim().split('+');
+ }
+ if(detailLevel === null || detailLevel.length === 0) return this.DEFAULT_DETAIL_LEVEL;
+
+ for(let level of detailLevel) {
+ if(this.AVAILABLE_DETAIL_LEVELS.indexOf(level) === -1) {
+ // @TODO custom error
+ throw new Error('Unknown detail level ' + level);
+ }
+ }
+
+ return detailLevel;
+ }
+} \ No newline at end of file
diff --git a/src/Repositories/FolderRepository.js b/src/Repositories/FolderRepository.js
new file mode 100644
index 0000000..1830293
--- /dev/null
+++ b/src/Repositories/FolderRepository.js
@@ -0,0 +1,20 @@
+import AbstractRepository from './AbstractRepository';
+
+export default class FolderRepository extends AbstractRepository {
+
+ /**
+ * @return {String[]}
+ * @constructor
+ */
+ get AVAILABLE_DETAIL_LEVELS() {
+ return ['id', 'model', 'revisions', 'parent', 'passwords', 'folders'];
+ }
+
+ /**
+ * @returns {String}
+ * @constructor
+ */
+ get TYPE() {
+ return 'folder';
+ }
+} \ No newline at end of file
diff --git a/src/Repositories/PasswordRepository.js b/src/Repositories/PasswordRepository.js
new file mode 100644
index 0000000..e160cba
--- /dev/null
+++ b/src/Repositories/PasswordRepository.js
@@ -0,0 +1,20 @@
+import AbstractRepository from './AbstractRepository';
+
+export default class PasswordRepository extends AbstractRepository {
+
+ /**
+ * @return {String[]}
+ * @constructor
+ */
+ get AVAILABLE_DETAIL_LEVELS() {
+ return ['id', 'model', 'revisions', 'folder', 'tags'];
+ }
+
+ /**
+ * @returns {String}
+ * @constructor
+ */
+ get TYPE() {
+ return 'password';
+ }
+} \ No newline at end of file
diff --git a/src/Repositories/SettingRepository.js b/src/Repositories/SettingRepository.js
new file mode 100644
index 0000000..0e695b9
--- /dev/null
+++ b/src/Repositories/SettingRepository.js
@@ -0,0 +1,123 @@
+export default class SettingRepository {
+
+ /**
+ *
+ * @param {BasicPasswordsClient} api
+ */
+ constructor(api) {
+ this._api = api;
+ /** @type SettingConverter **/
+ this._converter = api.getInstance(`converter.setting`);
+ }
+
+ /**
+ * @returns {Promise<SettingCollection>}
+ */
+ async findAll() {
+ let response = await this._api.getRequest()
+ .setPath(`1.0/settings/list`)
+ .send();
+
+ return this._converter.fromApiObject(response.getData());
+ }
+
+ /**
+ * @param {String} scope
+ * @returns {Promise<SettingCollection>}
+ */
+ async findByScope(scope) {
+ let response = await this._api.getRequest()
+ .setPath(`1.0/settings/list`)
+ .setData({scopes: [scope]})
+ .send();
+
+ return this._converter.fromApiObject(response.getData());
+ }
+
+ /**
+ * @param {String[]} scopes
+ * @returns {Promise<SettingCollection>}
+ */
+ async findByScopes(scopes) {
+ let response = await this._api.getRequest()
+ .setPath(`1.0/settings/list`)
+ .setData({scopes})
+ .send();
+
+ return this._converter.fromApiObject(response.getData());
+ }
+
+ /**
+ * @param {String} name
+ * @returns {Promise<SettingCollection>}
+ */
+ async findByName(name) {
+ let response = await this._api.getRequest()
+ .setPath(`1.0/settings/get`)
+ .setData([name])
+ .send();
+
+ return this._converter.fromApiObject(response.getData());
+ }
+
+ /**
+ * @param {String[]} names
+ * @returns {Promise<SettingCollection>}
+ */
+ async findByNames(names) {
+ let response = await this._api.getRequest()
+ .setPath(`1.0/settings/get`)
+ .setData(names)
+ .send();
+ return this._converter.fromApiObject(response.getData());
+ }
+
+ /**
+ *
+ * @param {Setting} setting
+ * @returns {Promise<void>}
+ */
+ async set(setting) {
+ let key = `${setting.getScope()}.${setting.getName()}`,
+ data = {};
+ data[key] = setting.getValue();
+
+ let response =
+ await this._api.getRequest()
+ .setPath(`1.0/settings/set`)
+ .setData(data)
+ .send();
+ }
+
+ /**
+ *
+ * @param {Setting} setting
+ * @returns {Promise<void>}
+ */
+ async reset(setting) {
+ let key = `${setting.getScope()}.${setting.getName()}`;
+ let response =
+ await this._api.getRequest()
+ .setPath(`1.0/settings/reset`)
+ .setData([key])
+ .send();
+ }
+
+ /**
+ *
+ * @param {Setting} setting
+ * @returns {Promise<void>}
+ */
+ update(setting) {
+ return this.set(setting);
+ }
+
+ /**
+ *
+ * @param {Setting} setting
+ * @returns {Promise<void>}
+ */
+ create(setting) {
+ return this.set(setting);
+ }
+} \ No newline at end of file
diff --git a/src/Repositories/TagRepository.js b/src/Repositories/TagRepository.js
new file mode 100644
index 0000000..8f7603e
--- /dev/null
+++ b/src/Repositories/TagRepository.js
@@ -0,0 +1,20 @@
+import AbstractRepository from './AbstractRepository';
+
+export default class TagRepository extends AbstractRepository {
+
+ /**
+ * @return {String[]}
+ * @constructor
+ */
+ get AVAILABLE_DETAIL_LEVELS() {
+ return ['id', 'model', 'revisions', 'passwords'];
+ }
+
+ /**
+ * @returns {String}
+ * @constructor
+ */
+ get TYPE() {
+ return 'tag';
+ }
+} \ No newline at end of file
diff --git a/src/Services/HashService.js b/src/Services/HashService.js
new file mode 100644
index 0000000..7942ae7
--- /dev/null
+++ b/src/Services/HashService.js
@@ -0,0 +1,100 @@
+import sodium from "libsodium-wrappers";
+
+export default class HashService {
+
+ get HASH_SHA_1() {
+ return 'SHA-1';
+ }
+
+ get HASH_SHA_256() {
+ return 'SHA-256';
+ }
+
+ get HASH_SHA_384() {
+ return 'SHA-384';
+ }
+
+ get HASH_SHA_512() {
+ return 'SHA-512';
+ }
+
+ get HASH_BLAKE2B() {
+ return 'BLAKE2b';
+ }
+
+ get HASH_BLAKE2B_224() {
+ return 'BLAKE2b-224';
+ }
+
+ get HASH_BLAKE2B_256() {
+ return 'BLAKE2b-256';
+ }
+
+ get HASH_BLAKE2B_384() {
+ return 'BLAKE2b-384';
+ }
+
+ get HASH_BLAKE2B_512() {
+ return 'BLAKE2b-512';
+ }
+
+ get HASH_ARGON2() {
+ return 'Argon2';
+ }
+
+ constructor(classLoader) {
+ this._ready = classLoader.getClass('state.boolean', false);
+
+ sodium.ready.then(() => {this._ready.set(true);});
+ }
+
+ /**
+ * Generate a hash of the given value with the given algorithm
+ *
+ * @param {String} value
+ * @param {String} [algorithm=SHA-1]
+ * @returns {Promise<string>}
+ */
+ async getHash(value, algorithm = 'SHA-1') {
+ await this._ready.awaitTrue();
+
+ if([this.HASH_SHA_1, this.HASH_SHA_256, this.HASH_SHA_384, this.HASH_SHA_512].indexOf(algorithm) !== -1) {
+ return await this._makeShaHash(value, algorithm);
+ } else if(algorithm.substr(0, 7) === this.HASH_BLAKE2B) {
+ return this._makeBlake2bHash(algorithm, value);
+ } else if(algorithm === this.HASH_ARGON2) {
+ return sodium.crypto_pwhash_str(value, sodium.crypto_pwhash_OPSLIMIT_MIN, sodium.crypto_pwhash_MEMLIMIT_MIN);
+ }
+ }
+
+ /**
+ *
+ * @param {String} value
+ * @param {String} algorithm
+ * @returns {Promise<String>}
+ * @private
+ */
+ async _makeShaHash(value, algorithm) {
+ let msgBuffer = new TextEncoder('utf-8').encode(value),
+ hashBuffer = await crypto.subtle.digest(algorithm, msgBuffer);
+
+ return sodium.to_hex(new Uint8Array(hashBuffer));
+ }
+
+ /**
+ * @param {String} algorithm
+ * @param {String} value
+ * @returns {String}
+ * @private
+ */
+ _makeBlake2bHash(algorithm, value) {
+ let bytes = sodium.crypto_generichash_BYTES_MAX;
+ if(algorithm.indexOf('-') !== -1) {
+ bytes = algorithm.split('-')[1];
+ if(sodium.crypto_generichash_BYTES_MAX < bytes) bytes = sodium.crypto_generichash_BYTES_MAX;
+ if(sodium.crypto_generichash_BYTES_MIN > bytes) bytes = sodium.crypto_generichash_BYTES_MIN;
+ }
+
+ return sodium.to_hex(sodium.crypto_generichash(bytes, sodium.from_string(value)));
+ }
+} \ No newline at end of file
diff --git a/src/Services/ModelService.js b/src/Services/ModelService.js
new file mode 100644
index 0000000..211b44d
--- /dev/null
+++ b/src/Services/ModelService.js
@@ -0,0 +1,283 @@
+export default class ModelService {
+
+ /**
+ *
+ * @param {BasicClassLoader} cl
+ */
+ constructor(cl) {
+ this._cl = cl;
+ this._cache = /** @type {Cache} **/ cl.getInstance('cache.cache');
+ }
+
+ hasModel(type, id) {
+ return this._cache.has(`${type}.${id}`);
+ }
+
+ getModel(type, id) {
+ if(this.hasModel(type, id)) {
+ return this._cache.get(`${type}.${id}`);
+ }
+ return null;
+ }
+
+ addModel(type, model) {
+ this._cache.set(`${type}.${model.getId()}`, model);
+ this._cache.set(`${type}.${model.getId()}.${model.getRevision()}`, model);
+ // @TODO update related models
+ }
+
+ /**
+ *
+ * @param type
+ * @param data
+ * @param detailLevel
+ * @returns {Promise<AbstractRevisionModel|Tag>|Promise<AbstractRevisionModel|Password>|Folder}
+ */
+ makeFromApiData(type, data, detailLevel = []) {
+ if(type === 'password') return this.makePasswordFromApiData(data, detailLevel)
+ if(type === 'folder') return this.makeFolderFromApiData(data, detailLevel)
+ if(type === 'tag') return this.makeTagFromApiData(data, detailLevel)
+ }
+
+ async makePasswordCollectionFromApiData(data, detailLevel = []) {
+ let passwords = [];
+ for(let passwordData of data) {
+ passwords.push(await this.makePasswordFromApiData(passwordData, detailLevel));
+ }
+
+ return this._cl.getClass('collection.password', passwords);
+ }
+
+ async makePasswordFromApiData(data, detailLevel = []) {
+ let converter = /** @type {PasswordConverter} **/ this._cl.getInstance('converter.password'),
+ newModel = await converter.fromEncryptedData(data);
+ newModel.setDetailLevel(detailLevel);
+
+ let revisionCacheKey = `password.${newModel.getId()}.${newModel.getRevision()}`;
+ if(!this._cache.has(revisionCacheKey)) {
+ let revisionModel = converter.fromModel(newModel);
+ this._cache.set(revisionCacheKey, revisionModel);
+ }
+ if(detailLevel.indexOf('folder') !== -1) {
+ newModel.setParent(await this.makeFolderFromApiData(data.folder, 'model'));
+ }
+ await this._makeCollectionFromData(newModel, 'password', 'tag', detailLevel, data);
+ await this._makeCollectionFromData(newModel, 'password', 'revision', detailLevel, data);
+
+ let cacheKey = `password.${newModel.getId()}`;
+ if(!this._cache.has(cacheKey)) {
+ this._cache.set(cacheKey, newModel);
+ return newModel;
+ } else {
+ return this._mergePasswordModel(this._cache.get(cacheKey), newModel);
+ }
+ }
+
+ async makeFolderCollectionFromApiData(data, detailLevel = []) {
+ let folders = [];
+ for(let folderData of data) {
+ folders.push(await this.makeFolderFromApiData(folderData, detailLevel));
+ }
+
+ return this._cl.getClass('collection.folder', folders);
+ }
+
+ /**
+ *
+ * @param data
+ * @param detailLevel
+ * @return {Folder}
+ */
+ async makeFolderFromApiData(data, detailLevel = []) {
+ let converter = /** @type {FolderConverter} **/ this._cl.getInstance('converter.folder'),
+ newModel = await converter.fromEncryptedData(data);
+ newModel.setDetailLevel(detailLevel);
+
+ let revisionCacheKey = `folder.${newModel.getId()}.${newModel.getRevision()}`;
+ if(!this._cache.has(revisionCacheKey)) {
+ let revisionModel = converter.fromModel(newModel);
+ this._cache.set(revisionCacheKey, revisionModel);
+ }
+ await this._makeCollectionFromData(newModel, 'folder', 'revision', detailLevel, data);
+ await this._makeCollectionFromData(newModel, 'folder', 'folder', detailLevel, data);
+ await this._makeCollectionFromData(newModel, 'folder', 'password', detailLevel, data);
+
+ if(detailLevel.indexOf('parent') !== -1) {
+ newModel.setParent(await this.makeFolderFromApiData(data.parent, 'model'));
+ }
+
+ let cacheKey = `folder.${newModel.getId()}`;
+ if(!this._cache.has(cacheKey)) {
+ this._cache.set(cacheKey, newModel);
+ return newModel;
+ } else {
+ return this._mergeFolderModel(this._cache.get(cacheKey), newModel);
+ }
+ }
+
+ async makeTagCollectionFromApiData(data, detailLevel = []) {
+ let tags = [];
+ for(let tagData of data) {
+ tags.push(await this.makeTagFromApiData(tagData, detailLevel));
+ }
+
+ return this._cl.getClass('collection.tag', tags);
+ }
+
+ async makeTagFromApiData(data, detailLevel = []) {
+ let converter = /** @type {TagConverter} **/ this._cl.getInstance('converter.tag'),
+ newModel = await converter.fromEncryptedData(data);
+ newModel.setDetailLevel(detailLevel);
+
+ let revisionCacheKey = `tag.${newModel.getId()}.${newModel.getRevision()}`;
+ if(!this._cache.has(revisionCacheKey)) {
+ let revisionModel = converter.fromModel(newModel);
+ this._cache.set(revisionCacheKey, revisionModel);
+ }
+ await this._makeCollectionFromData(newModel, 'tag', 'password', detailLevel, data);
+ await this._makeCollectionFromData(newModel, 'tag', 'revision', detailLevel, data);
+
+ let cacheKey = `tag.${newModel.getId()}`;
+ if(!this._cache.has(cacheKey)) {
+ this._cache.set(cacheKey, newModel);
+ return newModel;
+ } else {
+ return this._mergeTagModel(this._cache.get(cacheKey), newModel);
+ }
+ }
+
+ async _makeCollectionFromData(model, baseType, type, detailLevel, data) {
+ let property = `${type}s`;
+ if(detailLevel.indexOf(property) !== -1) {
+ if(!data.hasOwnProperty(property)) {
+ // @TODO data missing = potential bug?
+ }
+ let method = type === 'revision' ?
+ `_make${baseType[0].toUpperCase()}${baseType.substr(1)}Revision`:
+ `make${type[0].toUpperCase()}${type.substr(1)}FromApiData`,
+ models = [];
+ for(let modelData of data[property]) {
+ models.push(await this[method](modelData, 'model'));
+ }
+
+ let cacheKey = `${baseType}.${model.getId()}.${property}`;
+ if(this._cache.has(cacheKey)) {
+ let collection = /** @type {AbstractCollection} **/ this._cache.get(cacheKey);
+ collection.replaceAll(models);
+ model.setProperty(property, collection);
+ } else {
+ let collection = this._cl.getClass(type === 'revision' ? `collection.${baseType}`:`collection.${type}`, models);
+ this._cache.set(cacheKey, collection);
+ model.setProperty(property, collection);
+ }
+ }
+ }
+
+ /**
+ *
+ * @param {Password} model
+ * @param {Password} newModel
+ * @returns {Password}
+ * @private
+ */
+ _mergePasswordModel(model, newModel) {
+ this._mergeStandardProperties(model, newModel, ['tags', 'folder']);
+
+ if(newModel.getProperty('revisions') !== undefined) {
+ model.setProperty('revisions', newModel.getProperty('revisions'));
+ }
+ if(newModel.getProperty('tags') !== undefined) {
+ model.setProperty('tags', newModel.getProperty('tags'));
+ }
+ if(newModel.getProperty('folder') !== undefined) {
+ model.setProperty('folder', newModel.getProperty('folder'));
+ }
+ this._mergeModelDetailLevel(model, newModel.getDetailLevel());
+
+ return newModel;
+ }
+
+ /**
+ *
+ * @param {Folder} model
+ * @param {Folder} newModel
+ * @returns {Folder}
+ * @private
+ */
+ _mergeFolderModel(model, newModel) {
+ this._mergeStandardProperties(model, newModel, ['passwords', 'folders', 'parent']);
+
+ if(newModel.getProperty('revisions') !== undefined) {
+ model.setProperty('revisions', newModel.getProperty('revisions'));
+ }
+ if(newModel.getProperty('passwords') !== undefined) {
+ model.setProperty('passwords', newModel.getProperty('passwords'));
+ }
+ if(newModel.getProperty('folders') !== undefined) {
+ model.setProperty('folders', newModel.getProperty('folders'));
+ }
+ if(newModel.getProperty('parent') !== undefined) {
+ model.setProperty('parent', newModel.getProperty('parent'));
+ }
+ this._mergeModelDetailLevel(model, newModel.getDetailLevel());
+
+ return newModel;
+ }
+
+ /**
+ *
+ * @param {Tag} model
+ * @param {Tag} newModel
+ * @returns {Tag}
+ * @private
+ */
+ _mergeTagModel(model, newModel) {
+ this._mergeStandardProperties(model, newModel, ['passwords']);
+
+ if(newModel.getProperty('revisions') !== undefined) {
+ model.setProperty('revisions', newModel.getProperty('revisions'));
+ }
+ if(newModel.getProperty('passwords') !== undefined) {
+ model.setProperty('passwords', newModel.getProperty('passwords'));
+ }
+ this._mergeModelDetailLevel(model, newModel.getDetailLevel());
+
+ return newModel;
+ }
+
+ /**
+ *
+ * @param {AbstractRevisionModel} model
+ * @param {AbstractRevisionModel} newModel
+ * @param {String[]} excludeProperties
+ * @private
+ */
+ _mergeStandardProperties(model, newModel, excludeProperties) {
+ if(model.getRevision() === newModel.getRevision()) {
+ model.setUpdated(newModel.getUpdated());
+ }
+
+ excludeProperties.push('id, revisions')
+ let configuration = model.getPropertyConfiguration();
+ for(let key in configuration) {
+ if(configuration.hasOwnProperty(key) && excludeProperties.indexOf(key) === -1 && newModel.getProperty(key) !== undefined) {
+ model.setProperty(key, newModel.getProperty(key));
+ }
+ }
+ }
+
+ /**
+ * @param {AbstractRevisionModel} model
+ * @param {String[]} detailLevel
+ * @private
+ */
+ _mergeModelDetailLevel(model, detailLevel = []) {
+ let modelDetailLevel = model.getDetailLevel();
+ for(let level of detailLevel) {
+ if(modelDetailLevel.indexOf(level) === -1) {
+ modelDetailLevel.push(level);
+ }
+ }
+ model.setDetailLevel(modelDetailLevel);
+ }
+} \ No newline at end of file
diff --git a/src/Services/PasswordService.js b/src/Services/PasswordService.js
new file mode 100644
index 0000000..8e03212
--- /dev/null
+++ b/src/Services/PasswordService.js
@@ -0,0 +1,34 @@
+export default class PasswordService {
+
+ /**
+ *
+ * @param {BaseApi} api
+ */
+ constructor(api) {
+ this._api = api;
+ }
+
+ /**
+ *
+ * @param {Boolean} [numbers=null]
+ * @param {Boolean} [special=null]
+ * @param {Number} [strength=null]
+ * @returns {Promise<{password: String, words: String[], strength: Number, numbers: Boolean, special: Boolean}>}
+ */
+ async generate(numbers = null, special = null, strength = null) {
+ let request = this._api.getRequest().setPath('1.0/service/password');
+
+ if(numbers !== null || special !== null || strength !== null) {
+ let data = {};
+ if(numbers !== null) data.numbers = numbers;
+ if(special !== null) data.special = special;
+ if(strength !== null && strength >= 0 && strength <= 4) data.strength = parseInt(strength);
+
+ request.setData(data);
+ }
+
+ let response = await request.send();
+
+ return response.getData();
+ }
+} \ No newline at end of file
diff --git a/src/State/BooleanState.js b/src/State/BooleanState.js
new file mode 100644
index 0000000..3181fff
--- /dev/null
+++ b/src/State/BooleanState.js
@@ -0,0 +1,188 @@
+export default class BooleanState {
+
+ get value() {
+ return this.get();
+ }
+
+ set value(value) {
+ this.set(value);
+ }
+
+ constructor(value) {
+ this._value = value === true;
+ this._true = {
+ promise: null,
+ resolve: null
+ };
+ this._false = {
+ promise: null,
+ resolve: null
+ };
+ this._change = {
+ promise: null,
+ resolve: null
+ };
+ this._onTrue = [];
+ this._onFalse = [];
+ this._onChange = [];
+ }
+
+ get() {
+ return this._value;
+ }
+
+ set(value) {
+ this._value = value === true;
+ this._notify();
+ }
+
+ /**
+ *
+ * @returns {Promise<boolean>}
+ */
+ async awaitTrue() {
+ if(this._value) {
+ return new Promise((resolve) => resolve(true));
+ }
+
+ if(this._true.promise === null) {
+ this._true.promise = new Promise((resolve) => {
+ this._true.resolve = resolve;
+ });
+ }
+
+ return this._true.promise;
+ }
+
+ /**
+ *
+ * @returns {Promise<boolean>}
+ */
+ async awaitFalse() {
+ if(!this._value) {
+ return new Promise((resolve) => resolve(false));
+ }
+
+ if(this._false.promise === null) {
+ this._false.promise = new Promise((resolve) => {
+ this._false.resolve = resolve;
+ });
+ }
+
+ return this._false.promise;
+ }
+
+ /**
+ *
+ * @returns {Promise<boolean>}
+ */
+ async awaitChange() {
+ if(this._change.promise === null) {
+ this._change.promise = new Promise((resolve) => {
+ this._change.resolve = resolve;
+ });
+ }
+
+ return this._change.promise;
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ onTrue(callback) {
+ this._onTrue.push(callback);
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ offTrue(callback) {
+ this._off('_onTrue', callback);
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ onFalse(callback) {
+ this._onFalse.push(callback);
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ offFalse(callback) {
+ this._off('_onFalse', callback);
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ onChange(callback) {
+ this._onChange.push(callback);
+ }
+
+ /**
+ *
+ * @param {Function} callback
+ */
+ offChange(callback) {
+ this._off('_onChange', callback);
+ }
+
+ /**
+ *
+ * @returns {Boolean}
+ */
+ toJSON() {
+ return this._value;
+ }
+
+ /**
+ *
+ * @private
+ */
+ _notify() {
+ if(this._value) {
+ if(this._true.promise !== null) this._true.resolve(this);
+ this._true.promise = null;
+ this._notifyEvents('_onTrue');
+ } else {
+ if(this._false.promise !== null) this._false.resolve(this);
+ this._false.promise = null;
+ this._notifyEvents('_onFalse');
+ }
+
+ if(this._change.promise !== null) this._change.resolve(this, this._value);
+ this._change.promise = null;
+ this._notifyEvents('_onChange');
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @private
+ */
+ _notifyEvents(event) {
+ for(let callback of this[event]) {
+ callback(this, this._value);
+ }
+ }
+
+ /**
+ *
+ * @param {String} event
+ * @param {Function} callback
+ * @private
+ */
+ _off(event, callback) {
+ let index = this[event].indexOf(callback);
+ if(index !== -1) {
+ this[event].splice(index, 1);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Utility/ObjectClone.js b/src/Utility/ObjectClone.js
new file mode 100644
index 0000000..ca18f47
--- /dev/null
+++ b/src/Utility/ObjectClone.js
@@ -0,0 +1,33 @@
+class ObjectClone {
+
+ /**
+ *
+ * @param {Object} object
+ * @return {Object}
+ */
+ clone(object) {
+ if(typeof object !== 'object') return object;
+
+ let clone = new object.constructor();
+ for(let key in object) {
+ if(!object.hasOwnProperty(key)) continue;
+ let element = object[key];
+
+ if(Array.isArray(element)) {
+ clone[key] = element.slice(0);
+ } else if(element instanceof Date) {
+ clone[key] = new Date(element.getTime());
+ } else if(element === null) {
+ clone[key] = null;
+ } else if(typeof element === 'object') {
+ clone[key] = this.clone(element);
+ } else {
+ clone[key] = element;
+ }
+ }
+
+ return clone;
+ }
+}
+
+export default new ObjectClone(); \ No newline at end of file
diff --git a/src/Utility/ObjectMerger.js b/src/Utility/ObjectMerger.js
new file mode 100644
index 0000000..e1d31f5
--- /dev/null
+++ b/src/Utility/ObjectMerger.js
@@ -0,0 +1,28 @@
+import ObjectClone from './ObjectClone';
+
+class ObjectMerger {
+ merge(target, source) {
+ for(let key in source) {
+ if(!source.hasOwnProperty(key)) continue;
+
+ if(!target.hasOwnProperty(key) || target[key] === null) {
+ target[key] = ObjectClone.clone(source[key]);
+ }
+
+ let targetValue = target[key],
+ sourceValue = source[key];
+
+ if(typeof targetValue === 'object' && typeof sourceValue === 'object') {
+ target[key] = this.merge(targetValue, sourceValue);
+ } else if(Array.isArray(targetValue) && Array.isArray(sourceValue)) {
+ target[key] = targetValue.concat(sourceValue);
+ } else {
+ target[key] = ObjectClone.clone(sourceValue);
+ }
+ }
+
+ return target;
+ }
+}
+
+export default new ObjectMerger(); \ No newline at end of file
diff --git a/src/main.js b/src/main.js
index 7eb23ec..4f19add 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,31 @@
-import SimpleApi from './Classes/SimpleApi';
-import Encryption from './Classes/Encryption';
-import EnhancedApi from './Classes/EnhancedApi';
+import PassLink from './PassLink/PassLink';
+import PasswordsClient from './Client/PasswordsClient';
+import BasicPasswordsClient from './Client/BasicPasswordsClient';
+import EnhancedClassLoader from "./ClassLoader/EnhancedClassLoader";
+import DefaultClassLoader from "./ClassLoader/DefaultClassLoader";
+import BasicClassLoader from "./ClassLoader/BasicClassLoader";
+import EnhancedPassword from "./Model/Password/EnhancedPassword";
+import Password from "./Model/Password/Password";
+import EnhancedFolder from "./Model/Folder/EnhancedFolder";
+import Folder from "./Model/Folder/Folder";
+import EnhancedTag from "./Model/Tag/EnhancedTag";
+import Tag from "./Model/Tag/Tag";
+import Server from "./Model/Server/Server";
-export default EnhancedApi;
-export {EnhancedApi, SimpleApi, Encryption};
+export default PasswordsClient;
+
+export {
+ PasswordsClient,
+ BasicPasswordsClient,
+ EnhancedClassLoader,
+ DefaultClassLoader,
+ BasicClassLoader,
+ PassLink,
+ EnhancedPassword,
+ EnhancedFolder,
+ EnhancedTag,
+ Password,
+ Folder,
+ Tag,
+ Server
+};