Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Buttercup server integration #315

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
343 changes: 108 additions & 235 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"pako": "^1.0.11",
"path-posix": "^1.0.0",
"pify": "^5.0.0",
"pkce-challenge": "^3.0.0",
"url-join": "^4.0.1",
"uuid": "^8.3.2",
"webdav": "^4.10.0"
Expand All @@ -109,7 +110,7 @@
"concurrently": "^6.3.0",
"husky": "^4.3.8",
"istanbul": "^0.4.5",
"istanbul-instrumenter-loader": "^3.0.0",
"istanbul-instrumenter-loader": "^3.0.1",
"jsdoc-to-markdown": "^7.1.0",
"karma": "^6.3.8",
"karma-chrome-launcher": "^3.1.0",
Expand Down
140 changes: 140 additions & 0 deletions source/clients/ButtercupServerClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { request } from "cowl";
import joinURL from "url-join";
import { Layerr } from "layerr";
import { default as createChallenge } from "pkce-challenge";
import { encodeBase64String } from "../tools/encoding";

export interface ButtercupServerAuthURLOptions {
clientID: string;
redirectURI: string;
responseType?: "code" | "token";
serverURL: string;
state?: string;
}
export interface ButtercupServerCodeExchangeOptions {
clientID: string;
clientSecret: string;
redirectURI: string;
serverURL: string;
}

const API_ROOT = "/api/v1";
const API_ROUTE_VAULT = "/vault/[ID]";
const BASE_SCOPES = ["openid", "offline_access"];
const HEADER_VAULT_UPDATE_ID = "X-Bcup-Update-ID";

export async function fetchVault(
serverAddress: string,
token: string,
vaultID: number
): Promise<{ vault: string; updateID: string }> {
const url = joinURL(serverAddress, API_ROOT, API_ROUTE_VAULT).replace("[ID]", vaultID);
try {
const response = await request({
url,
method: "GET",
headers: {
Accept: "text/plain",
Authorization: `Bearer ${token}`
},
responseType: "text"
});
return {
vault: response.data,
updateID: response.headers[HEADER_VAULT_UPDATE_ID]
};
} catch (err) {
// Handle OAuth failure
}
}

export async function exchangeCodeForToken(
options: ButtercupServerCodeExchangeOptions,
pkceVerifier: string,
authCode: string
): Promise<{
accessToken: string;
expiresIn: number;
created: number;
refreshToken: string;
tokenType: string;
}> {
const url = joinURL(options.serverURL, "/oauth/token");
const created = Date.now();
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Basic " + encodeBase64String(`${options.clientID}:${options.clientSecret}`),
"Content-Type": "application/x-www-form-urlencoded"
},
body: [
"grant_type=authorization_code",
`code=${encodeURIComponent(authCode)}`,
`client_id=${options.clientID}`,
`redirect_uri=${encodeURIComponent(options.redirectURI)}`,
`code_verifier=${encodeURIComponent(pkceVerifier)}`
].join("&")
});
if (!response.ok) {
throw new Layerr(`Token exchange failed: Invalid response: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
accessToken: result.access_token,
expiresIn: result.expires_in,
created,
refreshToken: result.refresh_token || null,
tokenType: result.token_type
};
}

export function getAuthorizationURL(options: ButtercupServerAuthURLOptions): { url: string; pkceVerifier: string } {
const { code_verifier: codeVerifier, code_challenge: codeChallenge } = createChallenge(128);
const { clientID, redirectURI, responseType = "code", serverURL, state } = options;
const parameters = [
`client_id=${encodeURIComponent(clientID)}`,
`response_type=${responseType}`,
`redirect_uri=${encodeURIComponent(redirectURI)}`,
`code_challenge=${codeChallenge}`,
"code_challenge_method=S256",
`scope=${encodeURIComponent(BASE_SCOPES.join(" "))}`,
"prompt=consent",
state ? `state=${state}` : null
]
.filter(Boolean)
.join("&");
const url = joinURL(serverURL, `/oauth/auth?${parameters}`);
return {
url,
pkceVerifier: codeVerifier
};
}

export async function updateVault(
serverAddress: string,
token: string,
vaultID: number,
vault: string,
updateID: string
): Promise<{ updateID: string }> {
const url = joinURL(serverAddress, API_ROOT, API_ROUTE_VAULT).replace("[ID]", vaultID);
try {
const response = await request({
url,
method: "PUT",
headers: {
Accept: "text/plain",
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
[HEADER_VAULT_UPDATE_ID]: updateID
},
body: vault,
responseType: "text"
});
return {
updateID: response.headers[HEADER_VAULT_UPDATE_ID]
};
} catch (err) {
// Handle OAuth failure
}
}
11 changes: 10 additions & 1 deletion source/core/Entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export default class Entry extends VaultItem {
const group = vault.findGroupByID(parentGroupID);
if (!group) {
throw new Error(`Failed creating entry: no group found for ID: ${parentGroupID}`);
} else if (group.isTrash() || group.isInTrash()) {
}
group._requireWritePermission();
if (group.isTrash() || group.isInTrash()) {
throw new Error("Failed creating entry: cannot create within Trash group");
}
// Generate new entry ID
Expand All @@ -55,6 +57,7 @@ export default class Entry extends VaultItem {
* @memberof Entry
*/
delete(skipTrash: boolean = false): boolean {
this._requireWritePermission();
const trashGroup = this.vault.getTrashGroup();
const parentGroup = this.getGroup();
const canTrash = trashGroup && parentGroup && !parentGroup.isTrash() && !parentGroup.isInTrash();
Expand All @@ -81,6 +84,7 @@ export default class Entry extends VaultItem {
* @returns Self
*/
deleteAttribute(attribute: string): this {
this._requireWritePermission();
this.vault.format.deleteEntryAttribute(this.id, attribute);
return this;
}
Expand All @@ -93,6 +97,7 @@ export default class Entry extends VaultItem {
* @returns Self
*/
deleteProperty(property: string): this {
this._requireWritePermission();
this.vault.format.deleteEntryProperty(this.id, property);
return this;
}
Expand Down Expand Up @@ -231,6 +236,8 @@ export default class Entry extends VaultItem {
* @memberof Entry
*/
moveToGroup(group: Group): this {
this._requireWritePermission();
// @todo Detect moving outside of share range
this.vault.format.moveEntry(this.id, group.id);
return this;
}
Expand All @@ -243,6 +250,7 @@ export default class Entry extends VaultItem {
* @memberof Entry
*/
setAttribute(attribute: string, value: string): this {
this._requireWritePermission();
this.vault.format.setEntryAttribute(this.id, attribute, value);
return this;
}
Expand All @@ -255,6 +263,7 @@ export default class Entry extends VaultItem {
* @memberof Entry
*/
setProperty(property: string, value: string): this {
this._requireWritePermission();
this.vault.format.setEntryProperty(this.id, property, value);
return this;
}
Expand Down
15 changes: 13 additions & 2 deletions source/core/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import VaultItem from "./VaultItem";
import Entry from "./Entry";
import Vault from "./Vault";
import { generateUUID } from "../tools/uuid";
import { moveGroupBetweenVaults } from "../tools/sharing";
import { moveGroupBetweenVaults } from "../tools/group";
import { findGroupsByTitle, getAllChildGroups } from "../search/groups";
import { findEntriesByProperty, getAllChildEntries } from "../search/entries";
import { EntryID, GroupID } from "../types";
Expand Down Expand Up @@ -33,7 +33,9 @@ export default class Group extends VaultItem {
const group = vault.findGroupByID(parentID);
if (!group) {
throw new Error(`Failed creating group: no group found for ID: ${parentID}`);
} else if (group.isTrash() || group.isInTrash()) {
}
group._requireWritePermission();
if (group.isTrash() || group.isInTrash()) {
throw new Error("Failed creating group: cannot create within Trash group");
}
}
Expand All @@ -49,6 +51,7 @@ export default class Group extends VaultItem {
* @memberof Group
*/
createEntry(title?: string): Entry {
this._requireWritePermission();
const entry = Entry.createNew(this.vault, this.id);
if (title) {
entry.setProperty("title", title);
Expand All @@ -63,6 +66,7 @@ export default class Group extends VaultItem {
* @memberof Group
*/
createGroup(title?: string): Group {
this._requireWritePermission();
const group = Group.createNew(this.vault, this.id);
if (title) {
group.setTitle(title);
Expand All @@ -79,6 +83,7 @@ export default class Group extends VaultItem {
* @memberof Group
*/
delete(skipTrash: boolean = false): boolean {
this._requireWritePermission();
if (this.isTrash()) {
throw new Error("Trash group cannot be deleted");
}
Expand Down Expand Up @@ -112,6 +117,7 @@ export default class Group extends VaultItem {
* @memberof Group
*/
deleteAttribute(attr: string): this {
this._requireWritePermission();
this.vault.format.deleteGroupAttribute(this.id, attr);
return this;
}
Expand Down Expand Up @@ -249,6 +255,8 @@ export default class Group extends VaultItem {
* @memberof Group
*/
moveTo(target: Group | Vault): this {
this._requireWritePermission();
// @todo Detect moving outside of share range
if (this.isTrash()) {
throw new Error("Trash group cannot be moved");
}
Expand All @@ -274,6 +282,7 @@ export default class Group extends VaultItem {
// target is local, so create commands here
this.vault.format.moveGroup(this.id, targetGroupID);
} else {
this._requireMgmtPermission();
// target is in another archive, so move there
moveGroupBetweenVaults(this, target);
}
Expand All @@ -288,6 +297,7 @@ export default class Group extends VaultItem {
* @memberof Group
*/
setAttribute(attribute: string, value: string): this {
this._requireWritePermission();
this.vault.format.setGroupAttribute(this.id, attribute, value);
return this;
}
Expand All @@ -298,6 +308,7 @@ export default class Group extends VaultItem {
* @returns Returns self
*/
setTitle(title: string): this {
this._requireWritePermission();
this.vault.format.setGroupTitle(this.id, title);
return this;
}
Expand Down
27 changes: 27 additions & 0 deletions source/core/Share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ShareID, SharePermission } from "../types";

export default class Share {
_id: ShareID;
_key: string;
_permissions: Array<SharePermission>;
_updateID: string;

constructor(id: ShareID, updateID: string, key: string, permissions: Array<SharePermission>) {
this._id = id;
this._key = key;
this._updateID = updateID;
this._permissions = [...permissions];
}

get id(): ShareID {
return this._id;
}

get updateID(): string {
return this._updateID;
}

hasPermission(permission: SharePermission) {
return this._permissions.includes(permission);
}
}
12 changes: 11 additions & 1 deletion source/core/Vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { findGroupsByTitle } from "../search/groups";
import { findEntriesByProperty } from "../search/entries";
import Group from "./Group";
import Entry from "./Entry";
import { EntryID, GroupID, History } from "../types";
import VaultFormat from "../io/VaultFormat";
import { EntryID, FormatBShare, GroupID, History } from "../types";

/**
* Vault class - Contains Groups and Entrys
Expand Down Expand Up @@ -57,6 +57,8 @@ export default class Vault extends EventEmitter {

_onCommandExec: () => void;

_shares: Array<any> = [];

/**
* The vault format
* @readonly
Expand Down Expand Up @@ -273,6 +275,14 @@ export default class Vault extends EventEmitter {
this._entries.push(new Entry(this, rawEntry));
}
});
if (this.format.supportsShares()) {
this.format.getAllShares().forEach((rawShare: FormatBShare) => {
const id = this.format.getShareID(rawShare);
if (!this._shares.find(s => s.id === id)) {
this._shares.push(this.format.createShareInstance(rawShare));
}
});
}
}

/**
Expand Down
Loading