diff --git a/app/background-process.js b/app/background-process.js index 4e4d00cac8..ed0c30724b 100644 --- a/app/background-process.js +++ b/app/background-process.js @@ -26,6 +26,7 @@ import * as settings from './background-process/dbs/settings' import * as sitedata from './background-process/dbs/sitedata' import * as profileDataDb from './background-process/dbs/profile-data-db' import * as bookmarksDb from './background-process/dbs/bookmarks' +import * as servicesDb from './background-process/dbs/services' import * as beakerProtocol from './background-process/protocols/beaker' import * as beakerFaviconProtocol from './background-process/protocols/beaker-favicon' @@ -71,6 +72,7 @@ app.on('ready', async function () { archives.setup() settings.setup() sitedata.setup() + await servicesDb.setup() // TEMP can probably remove this in 2018 or so -prf bookmarksDb.fixOldBookmarks() diff --git a/app/background-process/dbs/services.js b/app/background-process/dbs/services.js new file mode 100644 index 0000000000..a12bc13969 --- /dev/null +++ b/app/background-process/dbs/services.js @@ -0,0 +1,221 @@ +import { app } from 'electron' +import sqlite3 from 'sqlite3' +import path from 'path' +import assert from 'assert' +import _keyBy from 'lodash.keyby' +import {parse as parseURL} from 'url' +import { cbPromise } from '../../lib/functions' +import {toOrigin} from '../../lib/bg/services' +import { setupSqliteDB } from '../../lib/bg/db' + +// globals +// = +var db +var migrations +var setupPromise + +// exported methods +// = + +export function setup () { + // open database + var dbPath = path.join(app.getPath('userData'), 'Services') + db = new sqlite3.Database(dbPath) + setupPromise = setupSqliteDB(db, {migrations}, '[SERVICES]') +} + +export async function addService (origin, psaDoc = null) { + await setupPromise + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + + // update service records + await cbPromise(cb => { + var title = psaDoc && typeof psaDoc.title === 'string' ? psaDoc.title : '' + var description = psaDoc && typeof psaDoc.description === 'string' ? psaDoc.description : '' + var createdAt = Date.now() + db.run( + `UPDATE services SET title=?, description=? WHERE origin=?`, + [title, description, origin], + cb + ) + db.run( + `INSERT OR IGNORE INTO services (origin, title, description, createdAt) VALUES (?, ?, ?, ?)`, + [origin, title, description, createdAt], + cb + ) + }) + + // remove any existing links + await cbPromise(cb => db.run(`DELETE FROM links WHERE origin = ?`, [origin], cb)) + + // add new links + if (psaDoc && psaDoc.links && Array.isArray(psaDoc.links)) { + // add one link per rel type + var links = [] + psaDoc.links.forEach(link => { + if (!(link && typeof link === 'object' && typeof link.href === 'string' && typeof link.rel === 'string')) { + return + } + var {rel, href, title} = link + rel.split(' ').forEach(rel => links.push({rel, href, title})) + }) + + // insert values + await Promise.all(links.map(link => cbPromise(cb => { + var {rel, href, title} = link + title = typeof title === 'string' ? title : '' + db.run( + `INSERT INTO links (origin, rel, href, title) VALUES (?, ?, ?, ?)`, + [origin, rel, href, title], + cb + ) + }))) + } +} + +export async function removeService (origin) { + await setupPromise + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + await cbPromise(cb => db.run(`DELETE FROM services WHERE origin = ?`, [origin], cb)) +} + +export async function addAccount (origin, username, password) { + await setupPromise + assert(origin && typeof origin === 'string', 'Origin must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + assert(password && typeof password === 'string', 'Password must be a string') + origin = toOrigin(origin) + + // delete existing account + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE origin = ? AND username = ?`, [origin, username], cb)) + + // add new account + await cbPromise(cb => db.run(`INSERT INTO accounts (origin, username, password) VALUES (?, ?, ?)`, [origin, username, password], cb)) +} + +export async function removeAccount (origin, username) { + await setupPromise + assert(origin && typeof origin === 'string', 'Origin must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + origin = toOrigin(origin) + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE origin = ? AND username = ?`, [origin, username], cb)) +} + +export async function getService (origin) { + var services = await listServices({origin}) + return Object.values(services)[0] +} + +export async function getAccount (origin, username) { + await setupPromise + origin = toOrigin(origin) + var query = 'SELECT username, password, origin FROM accounts WHERE origin = ? AND username = ?' + return cbPromise(cb => db.get(query, [origin, username], cb)) +} + +export async function listServices ({origin} = {}) { + await setupPromise + if (origin) { + origin = toOrigin(origin) + } + + // construct query + var where = ['1=1'] + var params = [] + if (origin) { + where.push('origin = ?') + params.push(origin) + } + where = where.join(' AND ') + + // run query + var query = ` + SELECT origin, title, description, createdAt + FROM services + WHERE ${where} + ` + var services = await cbPromise(cb => db.all(query, params, cb)) + + // get links on each + await Promise.all(services.map(async (service) => { + service.links = await listServiceLinks(service.origin) + service.accounts = await listServiceAccounts(service.origin) + })) + + return _keyBy(services, 'origin') // return as an object +} + +export async function listAccounts ({api} = {}) { + await setupPromise + + // construct query + var join = '' + var where = ['1=1'] + var params = [] + if (api) { + where.push('links.rel = ?') + params.push(api) + join = 'LEFT JOIN links ON links.origin = accounts.origin' + } + where = where.join(' AND ') + + // run query + var query = ` + SELECT accounts.username, accounts.origin + FROM accounts + ${join} + WHERE ${where} + ` + return cbPromise(cb => db.all(query, params, cb)) +} + +export async function listServiceLinks (origin) { + await setupPromise + origin = toOrigin(origin) + var query = 'SELECT rel, title, href FROM links WHERE origin = ?' + return cbPromise(cb => db.all(query, [origin], cb)) +} + +export async function listServiceAccounts (origin) { + await setupPromise + origin = toOrigin(origin) + var query = 'SELECT username FROM accounts WHERE origin = ?' + return cbPromise(cb => db.all(query, [origin], cb)) +} + +// internal methods +// = + +migrations = [ + // version 1 + function (cb) { + db.exec(` + CREATE TABLE services ( + origin TEXT PRIMARY KEY, + title TEXT, + description TEXT, + createdAt INTEGER + ); + CREATE TABLE accounts ( + origin TEXT, + username TEXT, + password TEXT, + createdAt INTEGER, + + FOREIGN KEY (origin) REFERENCES services (origin) ON DELETE CASCADE + ); + CREATE TABLE links ( + origin TEXT, + rel TEXT, + title TEXT, + href TEXT, + + FOREIGN KEY (origin) REFERENCES services (origin) ON DELETE CASCADE + ); + + PRAGMA user_version = 1; + `, cb) + } +] diff --git a/app/background-process/debug-logger.js b/app/background-process/debug-logger.js index 0df06d37e1..b38b3b9272 100644 --- a/app/background-process/debug-logger.js +++ b/app/background-process/debug-logger.js @@ -16,7 +16,7 @@ export default function setup () { let folderPath = process.env.beaker_user_data_path || app.getPath('userData') logFilePath = joinPath(folderPath, 'debug.log') console.log('Logfile:', logFilePath) - debug.enable('dat,datgc,dat-dns,dat-serve,dns-discovery,discovery-channel,discovery-swarm,beaker,beaker-sqlite,beaker-analytics') + debug.enable('dat,datgc,dat-dns,dat-serve,dns-discovery,discovery-channel,discovery-swarm,beaker,beaker-sqlite,beaker-analytics,beaker-service') debug.overrideUseColors() logFileWriteStream = fs.createWriteStream(logFilePath) diff --git a/app/background-process/services.js b/app/background-process/services.js new file mode 100644 index 0000000000..ef039716df --- /dev/null +++ b/app/background-process/services.js @@ -0,0 +1,157 @@ +import assert from 'assert' +import {join as joinPaths} from 'path' +import _get from 'lodash.get' +import _set from 'lodash.set' +import * as servicesDb from './dbs/services' +import {request, toOrigin, getAPIPathname} from '../lib/bg/services' +import {URL_HASHBASE, REL_ACCOUNT_API} from '../lib/const' +var debug = require('debug')('beaker-service') + +// globals +// = + +var debugRequestCounter = 0 +var psaDocs = {} +var sessions = {} + +// exported api +// = + +export async function fetchPSADoc (origin, {noCache} = {}) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + + // check cache + if (!noCache && origin in psaDocs) { + return {success: true, statusCode: 200, headers: {}, body: psaDocs[origin]} + } + + // do fetch + var n = ++debugRequestCounter + debug(`Request ${n} origin=${origin} path=/.well-known/psa method=GET (PSA doc fetch)`) + var res = await request({ + origin, + path: '/.well-known/psa' + }) + debug(`Response ${n} statusCode=${res.statusCode} body=`, res.body) + if (res.success && res.body && typeof res.body == 'object') { + let psaDoc = res.body + psaDocs[origin] = psaDoc + await servicesDb.addService(origin, psaDoc) + } + return res +} + +export async function makeAPIRequest ({origin, api, username, method, path, headers, body}) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + + // get params + try { + var session = username ? (await getOrCreateSession(origin, username)) : undefined + } catch (e) { + debug(`Session creation failed origin=${origin} session=${username} error=`, e) + return {success: false, statusCode: 0, headers: {}, body: {message: 'Need to log in'}} + } + var psaDocResponse = await fetchPSADoc(origin) + if (!psaDocResponse.success) return psaDocResponse + var apiPath = getAPIPathname(psaDocResponse.body, api) + path = path ? joinPaths(apiPath, path) : apiPath + + // make request + var n = ++debugRequestCounter + debug(`Request ${n} origin=${origin} path=${path} method=${method} session=${username}`) + var res = await request({origin, path, method, headers, session}, body) + debug(`Response ${n} statusCode=${res.statusCode}`) + return res +} + +export async function registerHashbase (body) { + // make the request + var n = ++debugRequestCounter + debug(`Request ${n} origin=${URL_HASHBASE} path=/v2/accounts/register method=POST`) + var res = await request({ + origin: URL_HASHBASE, + path: '/v2/accounts/register', + method: 'POST' + }, body) + debug(`Response ${n} statusCode=${res.statusCode}`) + + // add the account on success + if (res.success) { + await servicesDb.addAccount(URL_HASHBASE, body.username, body.password) + } + + return res +} + +export async function login (origin, username, password) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + + // make the login request + var res = await makeAPIRequest({ + origin, + api: REL_ACCOUNT_API, + method: 'POST', + path: '/login', + body: {username, password} + }) + + // store the session + if (res.success && res.body && res.body.sessionToken) { + _set(sessions, [origin, username], res.body.sessionToken) + } + + return res +} + +export async function logout (origin, username) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) + + // make the logout request + var res = await makeAPIRequest({ + origin, + api: REL_ACCOUNT_API, + username, + method: 'POST', + path: '/logout' + }) + + // clear the session + _set(sessions, [origin, username], null) + + return res +} + +// TODO needed? +// export async function getAccount (origin, username) { +// assert(origin && typeof origin === 'string', 'Origin must be a string') +// assert(username && typeof username === 'string', 'Username must be a string') +// return makeAPIRequest({ +// origin, +// api: REL_ACCOUNT_API, +// username, +// path: '/account' +// }) +// } + +// internal methods +// = + +async function getOrCreateSession (origin, username) { + // check cache + var session = _get(sessions, [origin, username]) + if (session) return session + + // lookup account credentials + var creds = await servicesDb.getAccount(origin, username) + if (!creds) { + throw new Error('Account not found') + } + + // do login + var res = await login(origin, creds.username, creds.password) + return res.body.sessionToken +} diff --git a/app/background-process/web-apis.js b/app/background-process/web-apis.js index a88b1737dd..0e759ce563 100644 --- a/app/background-process/web-apis.js +++ b/app/background-process/web-apis.js @@ -8,12 +8,14 @@ import downloadsManifest from '../lib/api-manifests/internal/downloads' import sitedataManifest from '../lib/api-manifests/internal/sitedata' import archivesManifest from '../lib/api-manifests/internal/archives' import historyManifest from '../lib/api-manifests/internal/history' +import servicesManifest from '../lib/api-manifests/internal/services' // import appsManifest from '../lib/api-manifests/internal/apps' // internal apis import archivesAPI from './web-apis/archives' import bookmarksAPI from './web-apis/bookmarks' import historyAPI from './web-apis/history' +import servicesAPI from './web-apis/services' // import appsAPI from './web-apis/apps' import {WEBAPI as sitedataAPI} from './dbs/sitedata' import {WEBAPI as downloadsAPI} from './ui/downloads' @@ -47,6 +49,7 @@ export function setup () { // rpc.exportAPI('apps', appsManifest, appsAPI, internalOnly) rpc.exportAPI('sitedata', sitedataManifest, sitedataAPI, internalOnly) rpc.exportAPI('downloads', downloadsManifest, downloadsAPI, internalOnly) + rpc.exportAPI('services', servicesManifest, servicesAPI, internalOnly) rpc.exportAPI('beaker-browser', beakerBrowserManifest, beakerBrowserAPI, internalOnly) // external apis diff --git a/app/background-process/web-apis/experimental/library.js b/app/background-process/web-apis/experimental/library.js index 8354f3e03c..9c2eb3a02b 100644 --- a/app/background-process/web-apis/experimental/library.js +++ b/app/background-process/web-apis/experimental/library.js @@ -6,11 +6,11 @@ import * as datLibrary from '../../networks/dat/library' import * as archivesDb from '../../dbs/archives' import {requestPermission} from '../../ui/permissions' import {PermissionsError, UserDeniedError} from 'beaker-error-constants' +import {URL_DOCS_LAB_API_LIBRARY} from '../../../lib/const' // constants // = -const API_DOCS_URL = 'https://TODO' // TODO const API_PERM_ID = 'experimentalLibrary' const REQUEST_ADD_PERM_ID = 'experimentalLibraryRequestAdd' const REQUEST_REMOVE_PERM_ID = 'experimentalLibraryRequestRemove' @@ -117,7 +117,7 @@ async function checkPerm (perm, sender) { } } if (!isOptedIn) { - throw new PermissionsError(`You must include "${LAB_API_ID}" in your dat.json experimental.apis list. See ${API_DOCS_URL} for more information.`) + throw new PermissionsError(`You must include "${LAB_API_ID}" in your dat.json experimental.apis list. See ${URL_DOCS_LAB_API_LIBRARY} for more information.`) } // ask user diff --git a/app/background-process/web-apis/services.js b/app/background-process/web-apis/services.js new file mode 100644 index 0000000000..36019af6d2 --- /dev/null +++ b/app/background-process/web-apis/services.js @@ -0,0 +1,33 @@ +import * as services from '../services' +import * as servicesDb from '../dbs/services' + +// exported api +// = + +export default { + // internal methods + + fetchPSADoc: services.fetchPSADoc, + makeAPIRequest: services.makeAPIRequest, + + registerHashbase: services.registerHashbase, + + login: services.login, + logout: services.logout, + + // db methods + + addService: servicesDb.addService, + removeService: servicesDb.removeService, + + addAccount: servicesDb.addAccount, + removeAccount: servicesDb.removeAccount, + + getService: servicesDb.getService, + getAccount: servicesDb.getAccount, + + listServices: servicesDb.listServices, + listAccounts: servicesDb.listAccounts, + listServiceLinks: servicesDb.listServiceLinks, + listServiceAccounts: servicesDb.listServiceAccounts +} diff --git a/app/builtin-pages/com/library-share-dropdown.js b/app/builtin-pages/com/library-share-dropdown.js new file mode 100644 index 0000000000..d41ccebfc9 --- /dev/null +++ b/app/builtin-pages/com/library-share-dropdown.js @@ -0,0 +1,192 @@ +/* globals DatArchive */ + +import * as yo from 'yo-yo' +import slugify from 'slugify' +import toggleable from './toggleable' +import * as dedicatedPeers from '../../lib/fg/dedicated-peers' +import {URL_DEDICATED_PEER_GUIDE} from '../../lib/const' + +// globals +// = + +var pinState +var expandedPeers = {} +var error + +// exported api +// = + +export default function render (archive) { + const renderInnerClosure = () => renderInner(archive) + return toggleable(yo` + + `, renderInnerClosure) +} + +// internal methods +// = + +function loadPinState (archive) { + dedicatedPeers.getAllPins(archive.url).then(({accounts, urls}) => { + pinState = {accounts, urls} + expandedPeers = {} + updatePage(archive) + }, console.error) +} + +function updatePage (archive) { + yo.update(document.body.querySelector('.library-share-dropdown .dropdown-items > div'), renderInner(archive)) +} + +function renderInner (archive) { + if (!pinState) loadPinState(archive) + + return yo` +
+ ${renderUrls(archive, pinState && pinState.urls)} + ${renderAccounts(archive, pinState && pinState.accounts)} +
` +} + +function renderUrls (archive, urls = []) { + return yo` +
+ ${renderUrl({label: 'Raw URL', url: archive.url})} + ${urls.map(url => renderUrl({url}))} +
` +} + +function renderAccounts (archive, accounts = []) { + return yo` +
+
+ Share with a dedicated peer + +
+ ${accounts.map((account, i) => renderAccount(archive, account, i))} + ${error ? yo`
${error}
` : ''} +
` +} + +function renderUrl ({label, url}) { + if (!label) { + label = url.startsWith('http') ? 'HTTP' : 'Dat' + } + return yo` +
+
+ ${label} + Copy URL +
+ +
` +} + +function renderAccount (archive, account, i) { + var {origin, username, isShared, datName} = account + return yo` +
+
+ ${origin} + ${username} + ${isShared + ? yo` onToggleAccount(archive, i)}>` + : yo` onAddPin(e, archive, account)}> Share`} +
+
+
+ + +
+ + ${isShared + ? yo` +
+ + +
` + : yo` +
+ +
`} +
+
` +} + +function onToggleAccount (archive, i) { + expandedPeers[i] = !expandedPeers[i] + updatePage(archive) +} + +async function onAddPin (e, archive, account) { + e.preventDefault() + + // render spinner + error = null + e.currentTarget.innerHTML = '
' + + // make request + var name = slugify(archive.info.title, {remove: /[^\w]/g}).toLowerCase() + var res = await dedicatedPeers.pinDat(account, archive.url, name || 'untitled') + + // update + if (res.success) { + loadPinState(archive) + } else { + console.error('Failed to share with peer', res) + error = res.body && typeof res.body.message === 'string' ? res.body.message : 'Failed to share with peer' + updatePage(archive) + } +} + +async function onUpdatePin (e, archive, account) { + e.preventDefault() + + // render spinner + error = null + e.currentTarget.innerHTML = '
' + + // make request + var name = e.currentTarget.form.datName.value + console.log(name) + var res = await dedicatedPeers.updatePin(account, archive.url, name) + + // update + if (res.success) { + loadPinState(archive) + } else { + console.error('Failed to remove from peer', res) + error = res.body && typeof res.body.message === 'string' ? res.body.message : 'Failed to remove from peer' + updatePage(archive) + } +} + +async function onDeletePin (e, archive, account) { + e.preventDefault() + + // render spinner + error = null + e.currentTarget.innerHTML = '
' + + // make request + var res = await dedicatedPeers.unpinDat(account, archive.url) + + // update + if (res.success) { + loadPinState(archive) + } else { + console.error('Failed to remove from peer', res) + error = res.body && typeof res.body.message === 'string' ? res.body.message : 'Failed to remove from peer' + updatePage(archive) + } +} + +function onClickURL (e) { + e.currentTarget.select() +} \ No newline at end of file diff --git a/app/builtin-pages/com/settings/dedicated-peers.js b/app/builtin-pages/com/settings/dedicated-peers.js new file mode 100644 index 0000000000..c7305a425a --- /dev/null +++ b/app/builtin-pages/com/settings/dedicated-peers.js @@ -0,0 +1,239 @@ +import yo from 'yo-yo' +import * as dedicatedPeers from '../../../lib/fg/dedicated-peers' + +// globals +// = + +var accounts +var formAction = false +var registerFields = [ + {name: 'username', label: 'Username', value: ''}, + {name: 'email', label: 'Email address', value: ''}, + {name: 'password', label: 'Password', value: '', type: 'password'}, + {name: 'passwordConfirm', label: 'Confirm password', value: '', type: 'password'} +] +var signinFields = [ + {name: 'service', label: 'Service', value: 'hashbase.io'}, + {name: 'username', label: 'Username', value: ''}, + {name: 'password', label: 'Password', value: '', type: 'password'}, +] +var registeredEmail = false +var errors = null + +// exported api +// = + +export default function renderDedicatedPeers (dedicatedPeerAccounts) { + if (!accounts) { + loadAccounts() + } + + return yo` +
+
+

Dedicated Peers

+ + ${registeredEmail + ? yo` +
+ +

Success! Check your email (${registeredEmail}) for a confirmation link to finish your setup.

+
` + : ''} + +

Dedicated peers keep your Dat data online, even when your computer is off.

+ + ${accounts ? accounts.map(renderPeer) : ''} + ${formAction + ? renderForm() + : yo` +
+ +
`} +
+
` +} + +// rendering +// = + +function renderPeer ({origin, username}) { + return yo` +
+ ${origin} + ${username} + + + + +
` +} + +function renderForm () { + if (!formAction) return '' + return yo` +
+

+ + +

+ ${formAction === 'register' ? renderRegisterForm() : renderSigninForm()} +
` +} + +function renderRegisterForm () { + return yo` +
+
+ ${errors ? yo`
${errors.message}
` : ''} + ${registerFields.map(renderInput)} +
+ +
+
+
` +} + +function renderSigninForm () { + return yo` +
+
+ ${errors ? yo`
${errors.message}
` : ''} + ${signinFields.map(renderInput)} +
+ +
+
+
` +} + +function renderInput (field, i) { + var {name, label, type, value} = field + var error = errors && errors.details && errors.details[name] + type = type || 'text' + return yo` +
+ + onChangeInput(e, field)} /> + ${error ? yo`
${error.msg}
` : ''} +
` +} + +// internal methods +// = + +function updatePage () { + yo.update(document.querySelector('.view'), renderDedicatedPeers()) +} + +async function loadAccounts () { + accounts = await dedicatedPeers.listAccounts() + formAction = (accounts.length === 0) ? 'register' : false + updatePage() +} + +function onChangeAddPeerAction (e, value) { + formAction = value || e.currentTarget.value + errors = null + registeredEmail = false + updatePage() + + // highlight first field + if (formAction == 'register') { + document.querySelector('input[name=username]').focus() + } else { + document.querySelector('input[name=service]').select() + } +} + +function onChangeInput (e, field) { + field.value = e.currentTarget.value +} + +async function onSubmitRegister (e) { + e.preventDefault() + + // attempt signin + errors = null + var res + var username = registerFields[0].value + var email = registerFields[1].value + var password = registerFields[2].value + var passwordConfirm = registerFields[3].value + try { + res = await beaker.services.registerHashbase({username, email, password, passwordConfirm}) + if (!res.success) { + if (res.body && typeof res.body.message === 'string') { + errors = res.body + } else if (typeof res.body === 'string' && (res.headers['content-type'] || '').indexOf('text/plain') !== -1) { + errors = {message: res.body} + } else { + errors = {message: 'There were errors in your submission'} + } + } + } catch (e) { + console.error(e) + errors = {message: e.toString()} + } + + // save + if (res && res.success) { + registeredEmail = email + // no need to add the account, `registerHashbase()` did that + loadAccounts() + } else { + updatePage() + } +} + +async function onSubmitSignin (e) { + e.preventDefault() + + // attempt signin + errors = null + var res + var origin = signinFields[0].value + var username = signinFields[1].value + var password = signinFields[2].value + try { + res = await beaker.services.login(origin, username, password) + if (!res.success) { + if (res.body && typeof res.body.message === 'string') { + errors = res.body + } else if (typeof res.body === 'string' && (res.headers['content-type'] || '').indexOf('text/plain') !== -1) { + errors = {message: res.body} + } else { + errors = {message: 'There were errors in your submission'} + } + } + } catch (e) { + console.error(e) + errors = {message: e.toString()} + } + + // save + if (res && res.success) { + await beaker.services.addAccount(origin, username, password) + loadAccounts() + } else { + updatePage() + } +} + +async function onRemoveAccount (origin, username) { + if (!confirm('Remove this account?')) { + return + } + + await beaker.services.logout(origin, username) + await beaker.services.removeAccount(origin, username) + loadAccounts() +} + diff --git a/app/builtin-pages/views/library-view.js b/app/builtin-pages/views/library-view.js index e26d97c974..7f237770ee 100644 --- a/app/builtin-pages/views/library-view.js +++ b/app/builtin-pages/views/library-view.js @@ -13,6 +13,7 @@ import renderPeerHistoryGraph from '../com/peer-history-graph' import * as toast from '../com/toast' import * as localsyncpathPopup from '../com/library-localsyncpath-popup' import * as copydatPopup from '../com/library-copydat-popup' +import renderShareDropdown from '../com/library-share-dropdown' import * as faviconPicker from '../com/favicon-picker' import renderSettingsField from '../com/settings-field' import {pluralize, shortenHash} from '../../lib/strings' @@ -285,14 +286,12 @@ function renderHeader () { ${getSafeTitle()} ` } - + ${shortenHash(archive.url)} - + ${renderShareDropdown(archive)} ` } diff --git a/app/builtin-pages/views/settings.js b/app/builtin-pages/views/settings.js index 53507f5042..4426a2024e 100644 --- a/app/builtin-pages/views/settings.js +++ b/app/builtin-pages/views/settings.js @@ -4,6 +4,7 @@ import yo from 'yo-yo' import * as toast from '../com/toast' import DatNetworkActivity from '../com/dat-network-activity' import renderBuiltinPagesNav from '../com/builtin-pages-nav' +import renderDedicatedPeers from '../com/settings/dedicated-peers' // globals // = @@ -72,22 +73,18 @@ function renderHeader () { } function renderSidebar () { + const item = (id, label) => yo` + ` + return yo`
- - - - - + ${item('general', 'General')} + ${item('dedicated-peers', 'Dedicated peers')} + ${item('dat-network-activity', 'Dat network activity')} + ${item('information', 'Information & Help')}
` } @@ -95,6 +92,8 @@ function renderView () { switch (activeView) { case 'general': return renderGeneral() + case 'dedicated-peers': + return renderDedicatedPeers() case 'dat-network-activity': return renderDatNetworkActivity() case 'information': @@ -218,8 +217,7 @@ function renderDatNetworkActivity () {

Dat Network Activity

${datNetworkActivity.render()} - - ` + ` } function renderInformation () { diff --git a/app/lib/api-manifests/internal/services.js b/app/lib/api-manifests/internal/services.js new file mode 100644 index 0000000000..edfcf380aa --- /dev/null +++ b/app/lib/api-manifests/internal/services.js @@ -0,0 +1,22 @@ +export default { + fetchPSADoc: 'promise', + makeAPIRequest: 'promise', + + registerHashbase: 'promise', + + login: 'promise', + logout: 'promise', + + addService: 'promise', + removeService: 'promise', + addAccount: 'promise', + removeAccount: 'promise', + + getService: 'promise', + getAccount: 'promise', + + listServices: 'promise', + listAccounts: 'promise', + listServiceLinks: 'promise', + listServiceAccounts: 'promise' +} \ No newline at end of file diff --git a/app/lib/bg/services.js b/app/lib/bg/services.js new file mode 100644 index 0000000000..cff8cbaa07 --- /dev/null +++ b/app/lib/bg/services.js @@ -0,0 +1,122 @@ +import assert from 'assert' +import http from 'http' +import https from 'https' +import {URL, parse as parseURL} from 'url' + +// exported api + +export function toOrigin (url = '') { + if (!url) return url + if (url.indexOf('://') === -1) url = 'https://' + url + return (new URL(url)).origin +} + +// opts: +// - origin: String, the scheme+hostname+port of the target machine +// - path: String, the url path (default '/') +// - method: String (default 'GET') +// - headers: Object +// - session: String +// returns object +// - success: Boolean +// - statusCode: Number +// - headers: Object +// - body: any +export function request (opts, body = undefined) { + return new Promise((resolve, reject) => { + var reqOpts = {headers: {}} + + // parse URL + var urlp + if (opts.origin.indexOf('://') === -1) { + let [hostname, port] = opts.origin.split(':') + let protocol = 'https:' + if (port) port = +port + urlp = {protocol, hostname, port} + } else { + urlp = parseURL(opts.origin) + } + reqOpts.protocol = urlp.protocol + reqOpts.hostname = urlp.hostname + reqOpts.path = opts.path || '/' + if (urlp.port) { + reqOpts.port = urlp.port + } + + // method + reqOpts.method = opts.method || 'GET' + + // add any headers + reqOpts.headers['Accept'] = 'application/json' + if (opts.headers) { + for (var k in opts.headers) { + reqOpts.headers[k] = opts.headers[k] + } + } + + // prepare body + if (body) { + body = JSON.stringify(body) + reqOpts.headers['Content-Type'] = 'application/json' + reqOpts.headers['Content-Length'] = Buffer.byteLength(body) + } + + // prepare session + if (opts.session) { + reqOpts.headers['Authorization'] = 'Bearer ' + opts.session + } + + // send request + var proto = urlp.protocol === 'http:' ? http : https + var req = proto.request(reqOpts, res => { + var resBody = '' + res.setEncoding('utf8') + res.on('data', chunk => { resBody += chunk }) + res.on('end', () => { + if (resBody) { + try { + resBody = JSON.parse(resBody) + } catch (e) {} + } + + // resolve + var statusCode = +res.statusCode + resolve({ + success: statusCode >= 200 && statusCode < 300, + statusCode, + headers: res.headers, + body: resBody + }) + }) + }) + req.on('error', err => resolve({ + success: false, + statusCode: 0, + headers: {}, + body: {message: err.toString()} + })) + if (body) { + req.write(body) + } + req.end() + }) +} + +export function getAPIPathname (psaDoc, relType, desc = 'needed') { + assert(psaDoc && typeof psaDoc === 'object', 'Invalid PSA service description document') + assert(psaDoc.links && Array.isArray(psaDoc.links), 'Invalid PSA service description document (no links array)') + var link = psaDoc.links.find(link => { + var rel = link.rel + return rel && typeof rel === 'string' && rel.indexOf(relType) !== -1 + }) + if (!link) { + throw new Error(`Service does not provide the ${desc} API (rel ${relType})`) + } + var href = link.href + assert(href && typeof href === 'string', 'Invalid PSA service description document (no href on API link)') + if (!href.startsWith('/')) { + var urlp = parseURL(href) + href = urlp.pathname + } + return href +} \ No newline at end of file diff --git a/app/lib/const.js b/app/lib/const.js index 33f1375a62..50e72a87b4 100644 --- a/app/lib/const.js +++ b/app/lib/const.js @@ -58,3 +58,13 @@ export const STANDARD_ARCHIVE_TYPES = [ 'videos', 'website' ] + +// URLs used in various places in the UI +export const URL_HASHBASE = process.env.beaker_hashbase_hostname || 'hashbase.io' +export const URL_DEDICATED_PEER_GUIDE = 'https://TODO' // TODO +export const URL_SELF_HOSTING_GUIDE = 'https://TODO' // TODO +export const URL_DOCS_LAB_API_LIBRARY = 'https://TODO' // TODO + +// rel-types +export const REL_ACCOUNT_API = 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-account-api' +export const REL_DATS_API = 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-dats-api' diff --git a/app/lib/fg/dedicated-peers.js b/app/lib/fg/dedicated-peers.js new file mode 100644 index 0000000000..bb5cebb3fd --- /dev/null +++ b/app/lib/fg/dedicated-peers.js @@ -0,0 +1,84 @@ +import {join as joinPaths} from 'path' +import {DAT_URL_REGEX, REL_DATS_API} from '../const' + +// exported api +// = + +export function listAccounts () { + return beaker.services.listAccounts({api: REL_DATS_API}) +} + +export async function getAllPins (datUrl) { + var accounts = await listAccounts() + var pins = await Promise.all(accounts.map(account => getPinAt(account, datUrl))) + var urls = [] + pins.forEach((pin, i) => { + accounts[i].isShared = pin.success + if (!pin.success) return + accounts[i].datName = pin.body.name + if (!pin.body || !pin.body.additionalUrls) return + urls = urls.concat(pin.body.additionalUrls) + }) + return {accounts, urls} +} + +export function getPinAt (account, datUrl) { + return beaker.services.makeAPIRequest({ + origin: account.origin, + username: account.username, + api: REL_DATS_API, + method: 'GET', + path: joinPaths('item', urlToKey(datUrl)) + }) +} + +export function updatePin (account, datUrl, datName) { + return beaker.services.makeAPIRequest({ + origin: account.origin, + username: account.username, + api: REL_DATS_API, + method: 'POST', + path: joinPaths('item', urlToKey(datUrl)), + body: { + name: datName + } + }) +} + +export function pinDat (account, datUrl, datName) { + return beaker.services.makeAPIRequest({ + origin: account.origin, + username: account.username, + api: REL_DATS_API, + method: 'POST', + path: '/add', + body: { + url: datUrl, + name: datName + } + }) +} + +export function unpinDat (account, datUrl) { + return beaker.services.makeAPIRequest({ + origin: account.origin, + username: account.username, + api: REL_DATS_API, + method: 'POST', + path: '/remove', + body: { + url: datUrl + } + }) +} + +// internal methods +// = + +function urlToKey (url) { + var match = (url || '').match(DAT_URL_REGEX) + if (match) { + return match[1].toLowerCase() + } + return url +} \ No newline at end of file diff --git a/app/lib/fg/services.js b/app/lib/fg/services.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/lib/web-apis/beaker.js b/app/lib/web-apis/beaker.js index 001561d704..85f954474b 100644 --- a/app/lib/web-apis/beaker.js +++ b/app/lib/web-apis/beaker.js @@ -12,6 +12,7 @@ import historyManifest from '../api-manifests/internal/history' import downloadsManifest from '../api-manifests/internal/downloads' // import appsManifest from '../api-manifests/internal/apps' import sitedataManifest from '../api-manifests/internal/sitedata' +import servicesManifest from '../api-manifests/internal/services' import beakerBrowserManifest from '../api-manifests/internal/browser' const beaker = {} @@ -45,6 +46,7 @@ if (window.location.protocol === 'beaker:') { // const appsRPC = rpc.importAPI('apps', appsManifest, opts) const downloadsRPC = rpc.importAPI('downloads', downloadsManifest, opts) const sitedataRPC = rpc.importAPI('sitedata', sitedataManifest, opts) + const servicesRPC = rpc.importAPI('services', servicesManifest, opts) const beakerBrowserRPC = rpc.importAPI('beaker-browser', beakerBrowserManifest, opts) // beaker.bookmarks @@ -131,6 +133,24 @@ if (window.location.protocol === 'beaker:') { beaker.sitedata.clearPermission = sitedataRPC.clearPermission beaker.sitedata.clearPermissionAllOrigins = sitedataRPC.clearPermissionAllOrigins + // beaker.services + beaker.services = {} + beaker.services.fetchPSADoc = servicesRPC.fetchPSADoc + beaker.services.makeAPIRequest = servicesRPC.makeAPIRequest + beaker.services.registerHashbase = servicesRPC.registerHashbase + beaker.services.login = servicesRPC.login + beaker.services.logout = servicesRPC.logout + beaker.services.addService = servicesRPC.addService + beaker.services.removeService = servicesRPC.removeService + beaker.services.addAccount = servicesRPC.addAccount + beaker.services.removeAccount = servicesRPC.removeAccount + beaker.services.getService = servicesRPC.getService + beaker.services.getAccount = servicesRPC.getAccount + beaker.services.listServices = servicesRPC.listServices + beaker.services.listAccounts = servicesRPC.listAccounts + beaker.services.listServiceLinks = servicesRPC.listServiceLinks + beaker.services.listServiceAccounts = servicesRPC.listServiceAccounts + // beaker.browser beaker.browser = {} beaker.browser.createEventsStream = () => fromEventStream(beakerBrowserRPC.createEventsStream()) diff --git a/app/package.json b/app/package.json index 3bfea194f2..086a817883 100644 --- a/app/package.json +++ b/app/package.json @@ -54,7 +54,9 @@ "lodash.flattendeep": "^4.4.0", "lodash.get": "^4.4.2", "lodash.groupby": "^4.6.0", + "lodash.keyby": "^4.6.0", "lodash.pick": "^4.4.0", + "lodash.set": "^4.3.2", "mime": "^1.4.0", "mkdirp": "^0.5.1", "moment": "^2.22.1", diff --git a/app/stylesheets/builtin-pages/components/library-share-dropdown.less b/app/stylesheets/builtin-pages/components/library-share-dropdown.less new file mode 100644 index 0000000000..4082ae083a --- /dev/null +++ b/app/stylesheets/builtin-pages/components/library-share-dropdown.less @@ -0,0 +1,127 @@ +.library-share-dropdown { + .dropdown-items { + padding: 20px; + width: 400px; + background: #fff; + } + + .message { + margin: 0; + flex-wrap: nowrap; + line-height: 1.2; + } + + .urls { + margin-bottom: 25px; + } + + .url-container { + margin-bottom: 15px; + } + + .url-header { + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 1px; + font-weight: 300; + + a { + color: @color-text--muted; + } + } + + .url-input { + display: block; + width: 100%; + background: #eee; + padding: 5px 10px; + border-radius: 4px; + border: 0; + + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + cursor: text; + } + + .peers-header { + font-size: 12px; + font-weight: 300; + padding-bottom: 2px; + } + + .peer-container { + .fa-angle-down, + .peer-controls { + display: none; + } + + .fa-angle-down { + width: 10px; + text-align: center; + } + + &.expanded { + .peer-controls { + display: block; + } + .fa-angle-down { + display: inline-block; + } + } + } + + .peer-header { + display: flex; + align-items: center; + padding: 0 0 4px; + font-size: 13px; + + .origin { + font-weight: 700; + margin-right: 5px; + } + + .username { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + + .status { + width: 28px; + } + + .fa-check { + color: rgb(55, 193, 79); + } + } + + .peer-controls { + padding: 10px; + background: #eee; + border-radius: 4px; + + & > div { + margin-bottom: 10px; + } + + label { + color: inherit; + font-size: 11px; + font-weight: 700; + padding-left: 1px; + } + + input { + width: 100%; + } + + .form-actions { + text-align: right; + margin-top: 10px; + margin-bottom: 0; + } + } +} \ No newline at end of file diff --git a/app/stylesheets/builtin-pages/library.less b/app/stylesheets/builtin-pages/library.less index 5278790dd8..f289764d00 100644 --- a/app/stylesheets/builtin-pages/library.less +++ b/app/stylesheets/builtin-pages/library.less @@ -12,6 +12,7 @@ @import "./components/files-browser.less"; @import "./components/diff"; @import "./components/favicon-picker"; +@import "./components/library-share-dropdown"; .window-content.builtin.library { // position relative so that popups stay in place @@ -252,6 +253,7 @@ body.drag { position: fixed; left: 0; top: 0; + z-index: 5000; display: block; width: 100%; height: auto; @@ -347,25 +349,13 @@ body.drag { } .url { + color: rgba(0,0,0,.5); margin-right: 5px; &:hover { text-decoration: underline; } } - - .url, - .btn { - color: rgba(0,0,0,.5); - } - - .btn { - margin-left: 0; - - &:hover { - color: @color-text; - } - } } .setup-info { diff --git a/app/stylesheets/builtin-pages/settings.less b/app/stylesheets/builtin-pages/settings.less index a937e8b9fd..8fa19f0dd5 100644 --- a/app/stylesheets/builtin-pages/settings.less +++ b/app/stylesheets/builtin-pages/settings.less @@ -119,6 +119,65 @@ } } + .peer { + display: flex; + align-items: center; + padding: 10px 14px 10px 16px; + margin-bottom: 10px; + border: 1px solid #ddd; + font-size: 14px; + + .name { + font-weight: 700; + margin-right: 10px; + } + + .user { + flex: 1; + color: gray; + font-weight: 300; + } + } + + .add-peer-form { + .action-choice { + display: flex; + + input { + height: auto; + margin: 0 5px; + } + + label { + margin: 0 10px 0 5px; + } + } + + .form-layout { + display: flex; + + & > div:first-child { + flex: 0 0 300px; + margin-right: 20px; + } + } + + .field { + display: flex; + flex-direction: column; + margin-bottom: 8px; + + label { + font-weight: normal; + } + } + + .form-actions { + text-align: right; + margin-top: 20px; + } + } + &.help { a { diff --git a/app/stylesheets/components/inputs.less b/app/stylesheets/components/inputs.less index fd0994aa9e..6dcd81b63f 100644 --- a/app/stylesheets/components/inputs.less +++ b/app/stylesheets/components/inputs.less @@ -115,6 +115,14 @@ label { font-weight: 500; } +.help-text { + font-size: 12px; + + &.error { + color: @red; + } +} + .toggle { &:extend(.flex); align-items: center; diff --git a/tests/package.json b/tests/package.json index 605ffa5b70..6a0733133a 100644 --- a/tests/package.json +++ b/tests/package.json @@ -5,6 +5,7 @@ "devDependencies": {}, "optionalDependencies": {}, "dependencies": { + "@beaker/homebase": "^1.1.4", "ava": "^0.23.0", "dat-node": "^3.0.0", "fs-jetpack": "^1.2.0", diff --git a/tests/services-web-api-test.js b/tests/services-web-api-test.js new file mode 100644 index 0000000000..9641aa87aa --- /dev/null +++ b/tests/services-web-api-test.js @@ -0,0 +1,399 @@ +import test from 'ava' +import os from 'os' +import path from 'path' +import fs from 'fs' +import Homebase from '@beaker/homebase' +import electron from '../node_modules/electron' + +import * as browserdriver from './lib/browser-driver' +import { shareDat } from './lib/dat-helpers' + +const REL_ACCOUNT_API = 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-account-api' +const REL_DATS_API = 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-dats-api' + +const LOCALHOST_PSA = { + PSA: 1, + description: 'Keep your Dats online!', + title: 'My Pinning Service', + links: [ + { href: '/v1/accounts', + rel: 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-account-api', + title: 'User accounts API' }, + { href: '/v1/dats', + rel: 'https://archive.org/services/purl/purl/datprotocol/spec/pinning-service-dats-api', + title: 'Dat pinning API' } + ] +} + +var server +const serverUrl = 'http://localhost:8888' + +const app = browserdriver.start({ + path: electron, + args: ['../app'], + env: { + NODE_ENV: 'test', + beaker_no_welcome_tab: 1, + beaker_user_data_path: fs.mkdtempSync(os.tmpdir() + path.sep + 'beaker-test-') + } +}) +test.before(async t => { + // setup the server + var config = new Homebase.HomebaseConfig() + config.canonical = { + domain: 'test.com', + webapi: { + username: 'admin', + password: 'hunter2' + }, + ports: { + http: 8888, + https: 8889 + } + } + server = Homebase.start(config) + + await app.isReady +}) +test.after.always('cleanup', async t => { + await app.stop() + await new Promise(r => server.close(r)) +}) + +test('manage services', async t => { + // add some services + var res = await app.executeJavascript(` + beaker.services.addService('foo.com', { + title: 'Foo Service', + description: 'It is foo', + links: [{ + rel: 'http://api-spec.com/address-book', + title: 'Foo User Listing API', + href: '/v1/users' + }, { + rel: 'http://api-spec.com/clock', + title: 'Get-current-time API', + href: '/v1/get-time' + }] + }) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addService('http://bar.com', { + title: 'Bar Service', + description: 'It is bar' + }) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addService('baz.com', { + links: [{ + rel: 'a b c', + title: 'Got links', + href: '/href' + }] + }) + `) + t.falsy(res) + + // list services + var res = await app.executeJavascript(` + beaker.services.listServices() + `) + massageServiceObj(res['https://foo.com']) + massageServiceObj(res['http://bar.com']) + massageServiceObj(res['https://baz.com']) + t.deepEqual(res, { + 'http://bar.com': { + accounts: [], + createdAt: 'number', + description: 'It is bar', + origin: 'http://bar.com', + links: [], + title: 'Bar Service' + }, + 'https://baz.com': { + accounts: [], + createdAt: 'number', + description: '', + origin: 'https://baz.com', + links: [ + {href: '/href', rel: 'a', title: 'Got links'}, + {href: '/href', rel: 'b', title: 'Got links'}, + {href: '/href', rel: 'c', title: 'Got links'} + ], + title: '' + }, + 'https://foo.com': { + accounts: [], + createdAt: 'number', + description: 'It is foo', + origin: 'https://foo.com', + links: [ + { + href: '/v1/users', + rel: 'http://api-spec.com/address-book', + title: 'Foo User Listing API' + }, + { + href: '/v1/get-time', + rel: 'http://api-spec.com/clock', + title: 'Get-current-time API' + } + ], + title: 'Foo Service' + } + }) + + // get service + var res = await app.executeJavascript(` + beaker.services.getService('https://baz.com') + `) + massageServiceObj(res) + t.deepEqual(res, { + accounts: [], + createdAt: 'number', + description: '', + origin: 'https://baz.com', + links: [ + {href: '/href', rel: 'a', title: 'Got links'}, + {href: '/href', rel: 'b', title: 'Got links'}, + {href: '/href', rel: 'c', title: 'Got links'} + ], + title: '' + }) + + // overwrite service + var res = await app.executeJavascript(` + beaker.services.addService('baz.com', { + links: [{ + rel: 'c d e', + title: 'Got links 2', + href: '/href2' + }] + }) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.getService('baz.com') + `) + massageServiceObj(res) + t.deepEqual(res, { + accounts: [], + createdAt: 'number', + description: '', + origin: 'https://baz.com', + links: [ + {href: '/href2', rel: 'c', title: 'Got links 2'}, + {href: '/href2', rel: 'd', title: 'Got links 2'}, + {href: '/href2', rel: 'e', title: 'Got links 2'} + ], + title: '' + }) + + // remove service + var res = await app.executeJavascript(` + beaker.services.removeService('bar.com') + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.getService('bar.com') + `) + t.falsy(res) +}) + +test('manage accounts', async t => { + // add some accounts + var res = await app.executeJavascript(` + beaker.services.addAccount('https://foo.com', 'alice', 'hunter2') + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addAccount('foo.com', 'bob', 'hunter2') + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addAccount('baz.com', 'alice', 'hunter2') + `) + t.falsy(res) + + // list accounts + var res = await app.executeJavascript(` + beaker.services.listAccounts() + `) + t.deepEqual(res, [ + {origin: 'https://foo.com', username: 'alice'}, + {origin: 'https://foo.com', username: 'bob'}, + {origin: 'https://baz.com', username: 'alice'} + ]) + + // list accounts (rel filter) + var res = await app.executeJavascript(` + beaker.services.listAccounts({api: 'http://api-spec.com/clock'}) + `) + t.deepEqual(res, [ + {origin: 'https://foo.com', username: 'alice'}, + {origin: 'https://foo.com', username: 'bob'} + ]) + + // get account + var res = await app.executeJavascript(` + beaker.services.getAccount('https://foo.com', 'alice') + `) + t.deepEqual(res, { + origin: 'https://foo.com', + username: 'alice', + password: 'hunter2' + }) + + // get service (will now include accounts) + var res = await app.executeJavascript(` + beaker.services.getService('https://baz.com') + `) + massageServiceObj(res) + t.deepEqual(res, { + accounts: [ + {username: 'alice'} + ], + createdAt: 'number', + description: '', + origin: 'https://baz.com', + links: [ + {href: '/href2', rel: 'c', title: 'Got links 2'}, + {href: '/href2', rel: 'd', title: 'Got links 2'}, + {href: '/href2', rel: 'e', title: 'Got links 2'} + ], + title: '' + }) + + // overwrite account + var res = await app.executeJavascript(` + beaker.services.addAccount('https://foo.com', 'alice', 'hunter3') + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.getAccount('https://foo.com', 'alice') + `) + t.deepEqual(res, { + origin: 'https://foo.com', + username: 'alice', + password: 'hunter3' + }) + + // remove account + var res = await app.executeJavascript(` + beaker.services.removeAccount('foo.com', 'alice') + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.getAccount('foo.com', 'alice') + `) + t.falsy(res) +}) + +test('fetchPSADoc', async t => { + // test valid host + var res = await app.executeJavascript(` + beaker.services.fetchPSADoc('http://localhost:8888') + `) + t.deepEqual(res.body, LOCALHOST_PSA) + + // test invalid host + var res = await app.executeJavascript(` + beaker.services.fetchPSADoc('localhost') + `) + t.falsy(res.success) +}) + +test('login / logout / makeAPIRequest', async t => { + // test without session + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + origin: 'http://localhost:8888', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `) + t.falsy(res.success) + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + origin: 'http://localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `) + t.falsy(res.success) + + // fail login + var res = await app.executeJavascript(` + beaker.services.login('http://localhost:8888', 'admin', 'wrongpassword') + `) + t.falsy(res.success) + + // login + var res = await app.executeJavascript(` + beaker.services.login('http://localhost:8888', 'admin', 'hunter2') + `) + t.is(res.statusCode, 200) + t.is(typeof res.body.sessionToken, 'string') + + // get account data + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + origin: 'http://localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `) + t.is(res.body.username, 'admin') + + // logout + var res = await app.executeJavascript(` + beaker.services.logout('http://localhost:8888', 'admin') + `) + t.is(res.statusCode, 200) + + // test without session + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + origin: 'http://localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `) + t.falsy(res.success) +}) + +test('login with stored credentials', async t => { + // add service + var res = await app.executeJavascript(` + beaker.services.addService('http://localhost:8888', ${JSON.stringify(LOCALHOST_PSA)}) + `) + t.falsy(res) + + // add account + var res = await app.executeJavascript(` + beaker.services.addAccount('http://localhost:8888', 'admin', 'hunter2') + `) + t.falsy(res) + + // get account data (no prior login) + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + origin: 'http://localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `) + t.is(res.body.username, 'admin') +}) + +function massageServiceObj (service) { + if (!service) return + service.createdAt = typeof service.createdAt + service.links.sort((a, b) => a.rel.localeCompare(b.rel)) +}