diff options
110 files changed, 6707 insertions, 52 deletions
@@ -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 +}; |