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`
+
+
+ ${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`
+
+
+
+
`
+}
+
+function renderAccount (archive, account, i) {
+ var {origin, username, isShared, datName} = account
+ return 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`
+ `
+}
+
+function renderRegisterForm () {
+ return yo`
+ `
+}
+
+function renderSigninForm () {
+ return yo`
+ `
+}
+
+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`
+ onUpdateView(id)}>
+
+ ${label}
+
`
+
return yo`
`
}
@@ -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))
+}