From 72219111a58cb8fc3a58379409e7bd0a2cbf72ff Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 9 May 2018 16:50:18 -0500 Subject: [PATCH 01/10] Add share dropdown to library-view (WIP) --- .../com/library-share-dropdown.js | 96 +++++++++++++ app/builtin-pages/views/library-view.js | 7 +- .../components/library-share-dropdown.less | 127 ++++++++++++++++++ app/stylesheets/builtin-pages/library.less | 16 +-- 4 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 app/builtin-pages/com/library-share-dropdown.js create mode 100644 app/stylesheets/builtin-pages/components/library-share-dropdown.less 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..3dbd4cfb9f --- /dev/null +++ b/app/builtin-pages/com/library-share-dropdown.js @@ -0,0 +1,96 @@ +/* globals DatArchive */ + +import * as yo from 'yo-yo' +import toggleable from './toggleable' + +// exported api +// = + +export default function render (archive) { + const renderInnerClosure = () => renderInner(archive) + return toggleable(yo` + + `, renderInnerClosure) +} + +// internal methods +// = + +function renderInner (archive) { + var el = yo` +
+
+ ${renderUrl({label: 'Raw URL', url: archive.url})} + ${renderUrl({label: 'hashbase.io', url: 'dat://foo-pfrazee.hashbase.io'})} +
+
+
Share with a peer
+ ${renderPeer({hostname: 'hashbase.io', isShared: true, name: 'foo', urls: ['dat://foo-pfrazee.hashbase.io']})} + ${renderPeer({hostname: 'taravancil.com', isShared: false})} +
+
+ + Add a peer +
+
+
+
` + + return el +} + +function renderUrl ({label, url}) { + return yo` +
+
+ ${label} + Copy URL +
+ +
` +} + +function renderPeer ({hostname, isShared, name}) { + return yo` +
+
+ ${isShared + ? yo`` + : yo``} + ${hostname} +
+
+
Share with ${hostname}
+ +
+ + +
+ + ${isShared + ? yo` +
+ + +
` + : yo` +
+ +
`} +
+
` +} + +function onClickPeer (e) { + e.currentTarget.parentNode.classList.toggle('expanded') +} + +function onClickURL (e) { + e.currentTarget.select() +} \ No newline at end of file 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/stylesheets/builtin-pages/components/library-share-dropdown.less b/app/stylesheets/builtin-pages/components/library-share-dropdown.less new file mode 100644 index 0000000000..fec614cdbf --- /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; + } + + .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: 8px; + font-size: 13px; + + cursor: pointer; + * { + cursor: pointer; + } + + &:hover { + color: gray; + } + + .status { + width: 28px; + } + + .fa-check { + color: rgb(55, 193, 79); + } + + .fa-upload { + padding-left: 1px; + } + + .fa-plus { + padding-left: 2px; + } + } + + .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 { From 27d4c29842f9c7ec237081c160bb8b695cdc53ee Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 10 May 2018 11:57:43 -0500 Subject: [PATCH 02/10] Add 'dedicated peers' section to settings (WIP) --- .../web-apis/experimental/library.js | 4 +- .../com/settings/dedicated-peers.js | 142 ++++++++++++++++++ app/builtin-pages/views/settings.js | 30 ++-- app/lib/const.js | 5 + app/stylesheets/builtin-pages/settings.less | 59 ++++++++ 5 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 app/builtin-pages/com/settings/dedicated-peers.js 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/builtin-pages/com/settings/dedicated-peers.js b/app/builtin-pages/com/settings/dedicated-peers.js new file mode 100644 index 0000000000..78a70a6de4 --- /dev/null +++ b/app/builtin-pages/com/settings/dedicated-peers.js @@ -0,0 +1,142 @@ +import yo from 'yo-yo' + +// globals +// = + +var formAction = '' +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'}, +] + +// exported api +// = + +export default function renderDedicatedPeers () { + return yo` +
+
+

Dedicated Peers

+

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

+ + ${renderPeer({name: 'hashbase.io', user: 'pfrazee'})} + ${renderPeer({name: 'paulfrazee.com', user: 'admin'})} + + ${formAction + ? renderForm() + : yo` +
+ +
`} + + ${''/*

+ Sign up at Hashbase.io + or read our self-hosting guide (advanced). +

*/} +
+ ${''/*
+

Advanced users

+

+ You can run your own dedicated peer using Homebase. +

+
*/} +
` +} + +// rendering +// = + +function renderPeer ({name, user}) { + return yo` +
+ ${name} + ${user} + + + + +
` +} + +function renderForm () { + if (!formAction) return '' + return yo` +
+

+ + +

+ ${formAction === 'register' ? renderRegisterForm() : renderSigninForm()} +
` +} + +function renderRegisterForm () { + return yo` +
+
+ ${registerFields.map(renderInput)} +
+ +
+
+
` +} + + +function renderSigninForm () { + return yo` +
+
+ ${signinFields.map(renderInput)} +
+ +
+
+
` +} + +function renderInput (field, i) { + var {name, label, type, value} = field + type = type || 'text' + return yo` +
+ + onChangeInput(e, field)} /> +
` +} + +// internal methods +// = + +function updatePage () { + yo.update(document.querySelector('.view'), renderDedicatedPeers()) +} + +function onChangeAddPeerAction (e, value) { + formAction = value || e.currentTarget.value + 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 +} 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/const.js b/app/lib/const.js index 33f1375a62..f499366c8c 100644 --- a/app/lib/const.js +++ b/app/lib/const.js @@ -58,3 +58,8 @@ export const STANDARD_ARCHIVE_TYPES = [ 'videos', 'website' ] + +// URLs used in various places in the UI +export const URL_HASHBASE_SIGNUP = 'https://hashbase.io/register' +export const URL_SELF_HOSTING_GUIDE = 'https://TODO' // TODO +export const URL_DOCS_LAB_API_LIBRARY = 'https://TODO' // TODO 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 { From 0b0acc17fa55918bb122501dff248e6da4e84166 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 10 May 2018 15:03:00 -0500 Subject: [PATCH 03/10] Add services DB and beaker.services internal web api --- app/background-process.js | 2 + app/background-process/dbs/services.js | 239 +++++++++++++++++++ app/background-process/web-apis.js | 3 + app/lib/api-manifests/internal/services.js | 14 ++ app/lib/web-apis/beaker.js | 15 ++ app/package.json | 1 + tests/services-web-api-test.js | 261 +++++++++++++++++++++ 7 files changed, 535 insertions(+) create mode 100644 app/background-process/dbs/services.js create mode 100644 app/lib/api-manifests/internal/services.js create mode 100644 tests/services-web-api-test.js 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..eb57b3099b --- /dev/null +++ b/app/background-process/dbs/services.js @@ -0,0 +1,239 @@ +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 { 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 const WEBAPI = { + addService, + removeService, + addAccount, + removeAccount, + + getService, + getAccount, + + listServices, + listAccounts, + listServiceLinks, + listServiceAccounts +} + +export async function addService (hostname, psaDoc = null) { + await setupPromise + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + hostname = getHostname(hostname) + + // 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 hostname=?`, + [title, description, hostname], + cb + ) + db.run( + `INSERT OR IGNORE INTO services (hostname, title, description, createdAt) VALUES (?, ?, ?, ?)`, + [hostname, title, description, createdAt], + cb + ) + }) + + // remove any existing links + await cbPromise(cb => db.run(`DELETE FROM links WHERE serviceHostname = ?`, [hostname], 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 (serviceHostname, rel, href, title) VALUES (?, ?, ?, ?)`, + [hostname, rel, href, title], + cb + ) + }))) + } +} + +export async function removeService (hostname) { + await setupPromise + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + hostname = getHostname(hostname) + await cbPromise(cb => db.run(`DELETE FROM services WHERE hostname = ?`, [hostname], cb)) +} + +export async function addAccount (hostname, {username, password}) { + await setupPromise + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + assert(password && typeof password === 'string', 'Password must be a string') + hostname = getHostname(hostname) + + // delete existing account + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE serviceHostname = ? AND username = ?`, [hostname, username], cb)) + + // add new account + await cbPromise(cb => db.run(`INSERT INTO accounts (serviceHostname, username, password) VALUES (?, ?, ?)`, [hostname, username, password], cb)) +} + +export async function removeAccount (hostname, username) { + await setupPromise + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + hostname = getHostname(hostname) + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE serviceHostname = ? AND username = ?`, [hostname, username], cb)) +} + +export async function getService (hostname) { + var services = await listServices({hostname}) + return Object.values(services)[0] +} + +export async function getAccount (hostname, username) { + await setupPromise + hostname = getHostname(hostname) + var query = 'SELECT username, password, serviceHostname FROM accounts WHERE serviceHostname = ? AND username = ?' + return cbPromise(cb => db.get(query, [hostname, username], cb)) +} + +export async function listServices ({hostname} = {}) { + await setupPromise + if (hostname) { + hostname = getHostname(hostname) + } + + // construct query + var where = ['1=1'] + var params = [] + if (hostname) { + where.push('hostname = ?') + params.push(hostname) + } + where = where.join(' AND ') + + // run query + var query = ` + SELECT hostname, 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.hostname) + service.accounts = await listServiceAccounts(service.hostname) + })) + + return _keyBy(services, 'hostname') // return as an object +} + +export async function listAccounts ({rel} = {}) { + await setupPromise + + // construct query + var join = '' + var where = ['1=1'] + var params = [] + if (rel) { + where.push('links.rel=?') + params.push(rel) + join = 'LEFT JOIN links ON links.serviceHostname = accounts.serviceHostname' + } + where = where.join(' AND ') + + // run query + var query = ` + SELECT accounts.username, accounts.serviceHostname + FROM accounts + ${join} + WHERE ${where} + ` + return cbPromise(cb => db.all(query, params, cb)) +} + +export async function listServiceLinks (hostname) { + await setupPromise + hostname = getHostname(hostname) + var query = 'SELECT rel, title, href FROM links WHERE serviceHostname = ?' + return cbPromise(cb => db.all(query, [hostname], cb)) +} + +export async function listServiceAccounts (hostname) { + await setupPromise + hostname = getHostname(hostname) + var query = 'SELECT username FROM accounts WHERE serviceHostname = ?' + return cbPromise(cb => db.all(query, [hostname], cb)) +} + +// internal methods +// = + +function getHostname (url) { + return parseURL(url).hostname || url +} + +migrations = [ + // version 1 + function (cb) { + db.exec(` + CREATE TABLE services ( + hostname TEXT PRIMARY KEY, + title TEXT, + description TEXT, + createdAt INTEGER + ); + CREATE TABLE accounts ( + serviceHostname TEXT, + username TEXT, + password TEXT, + createdAt INTEGER, + + FOREIGN KEY (serviceHostname) REFERENCES services (hostname) ON DELETE CASCADE + ); + CREATE TABLE links ( + serviceHostname TEXT, + rel TEXT, + title TEXT, + href TEXT, + + FOREIGN KEY (serviceHostname) REFERENCES services (hostname) ON DELETE CASCADE + ); + + PRAGMA user_version = 1; + `, cb) + } +] diff --git a/app/background-process/web-apis.js b/app/background-process/web-apis.js index a88b1737dd..7850667f38 100644 --- a/app/background-process/web-apis.js +++ b/app/background-process/web-apis.js @@ -8,6 +8,7 @@ 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 @@ -18,6 +19,7 @@ import historyAPI from './web-apis/history' import {WEBAPI as sitedataAPI} from './dbs/sitedata' import {WEBAPI as downloadsAPI} from './ui/downloads' import {WEBAPI as beakerBrowserAPI} from './browser' +import {WEBAPI as servicesAPI} from './dbs/services' // external manifests import datArchiveManifest from '../lib/api-manifests/external/dat-archive' @@ -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/lib/api-manifests/internal/services.js b/app/lib/api-manifests/internal/services.js new file mode 100644 index 0000000000..21c62756cd --- /dev/null +++ b/app/lib/api-manifests/internal/services.js @@ -0,0 +1,14 @@ +export default { + 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/web-apis/beaker.js b/app/lib/web-apis/beaker.js index 001561d704..3dbb38b412 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,19 @@ if (window.location.protocol === 'beaker:') { beaker.sitedata.clearPermission = sitedataRPC.clearPermission beaker.sitedata.clearPermissionAllOrigins = sitedataRPC.clearPermissionAllOrigins + // beaker.services + beaker.services = {} + 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..2d4b96d1cb 100644 --- a/app/package.json +++ b/app/package.json @@ -54,6 +54,7 @@ "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", "mime": "^1.4.0", "mkdirp": "^0.5.1", diff --git a/tests/services-web-api-test.js b/tests/services-web-api-test.js new file mode 100644 index 0000000000..fdc7013194 --- /dev/null +++ b/tests/services-web-api-test.js @@ -0,0 +1,261 @@ +import test from 'ava' +import os from 'os' +import path from 'path' +import fs from 'fs' +import electron from '../node_modules/electron' + +import * as browserdriver from './lib/browser-driver' +import { shareDat } from './lib/dat-helpers' + +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 => { + await app.isReady +}) +test.after.always('cleanup', async t => { + await app.stop() +}) + +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('https://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['foo.com']) + massageServiceObj(res['bar.com']) + massageServiceObj(res['baz.com']) + t.deepEqual(res, { + 'bar.com': { + accounts: [], + createdAt: 'number', + description: 'It is bar', + hostname: 'bar.com', + links: [], + title: 'Bar Service' + }, + 'baz.com': { + accounts: [], + createdAt: 'number', + description: '', + hostname: '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: '' + }, + 'foo.com': { + accounts: [], + createdAt: 'number', + description: 'It is foo', + hostname: '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: '', + hostname: '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: '', + hostname: '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('foo.com', {username: 'alice', password: 'hunter2'}) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addAccount('foo.com', {username: 'bob', password: 'hunter2'}) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.addAccount('baz.com', {username: 'alice', password: 'hunter2'}) + `) + t.falsy(res) + + // list accounts + var res = await app.executeJavascript(` + beaker.services.listAccounts() + `) + t.deepEqual(res, [ + {serviceHostname: 'foo.com', username: 'alice'}, + {serviceHostname: 'foo.com', username: 'bob'}, + {serviceHostname: 'baz.com', username: 'alice'} + ]) + + // list accounts (rel filter) + var res = await app.executeJavascript(` + beaker.services.listAccounts({rel: 'http://api-spec.com/clock'}) + `) + t.deepEqual(res, [ + {serviceHostname: 'foo.com', username: 'alice'}, + {serviceHostname: 'foo.com', username: 'bob'} + ]) + + // get account + var res = await app.executeJavascript(` + beaker.services.getAccount('foo.com', 'alice') + `) + t.deepEqual(res, { + serviceHostname: '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: '', + hostname: '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('foo.com', {username: 'alice', password: 'hunter3'}) + `) + t.falsy(res) + var res = await app.executeJavascript(` + beaker.services.getAccount('foo.com', 'alice') + `) + t.deepEqual(res, { + serviceHostname: '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) +}) + +function massageServiceObj (service) { + if (!service) return + service.createdAt = typeof service.createdAt + service.links.sort((a, b) => a.rel.localeCompare(b.rel)) +} From 124f2251f24b0fd69922e800c80236a044e5d072 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 10 May 2018 15:49:47 -0500 Subject: [PATCH 04/10] Keep the port in the services DB --- app/background-process/dbs/services.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/background-process/dbs/services.js b/app/background-process/dbs/services.js index eb57b3099b..940fa818d3 100644 --- a/app/background-process/dbs/services.js +++ b/app/background-process/dbs/services.js @@ -203,7 +203,7 @@ export async function listServiceAccounts (hostname) { // = function getHostname (url) { - return parseURL(url).hostname || url + return parseURL(url).host || url } migrations = [ From c2435e3ed27587482fe49541f202a2309e982d6c Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 10 May 2018 20:08:20 -0500 Subject: [PATCH 05/10] Add more service management and session tools --- app/background-process/dbs/services.js | 72 ++++----- app/background-process/services.js | 133 +++++++++++++++++ app/background-process/web-apis.js | 2 +- app/background-process/web-apis/services.js | 33 +++++ app/lib/api-manifests/internal/services.js | 8 + app/lib/bg/services.js | 111 ++++++++++++++ app/lib/const.js | 4 + app/lib/fg/dedicated-peers.js | 62 ++++++++ app/lib/fg/services.js | 0 app/lib/web-apis/beaker.js | 5 + app/package.json | 1 + tests/package.json | 1 + tests/services-web-api-test.js | 155 +++++++++++++++++++- 13 files changed, 533 insertions(+), 54 deletions(-) create mode 100644 app/background-process/services.js create mode 100644 app/background-process/web-apis/services.js create mode 100644 app/lib/bg/services.js create mode 100644 app/lib/fg/dedicated-peers.js create mode 100644 app/lib/fg/services.js diff --git a/app/background-process/dbs/services.js b/app/background-process/dbs/services.js index 940fa818d3..999ce4f787 100644 --- a/app/background-process/dbs/services.js +++ b/app/background-process/dbs/services.js @@ -5,6 +5,7 @@ import assert from 'assert' import _keyBy from 'lodash.keyby' import {parse as parseURL} from 'url' import { cbPromise } from '../../lib/functions' +import {toHostname} from '../../lib/bg/services' import { setupSqliteDB } from '../../lib/bg/db' // globals @@ -23,25 +24,10 @@ export function setup () { setupPromise = setupSqliteDB(db, {migrations}, '[SERVICES]') } -export const WEBAPI = { - addService, - removeService, - addAccount, - removeAccount, - - getService, - getAccount, - - listServices, - listAccounts, - listServiceLinks, - listServiceAccounts -} - export async function addService (hostname, psaDoc = null) { await setupPromise assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = getHostname(hostname) + hostname = toHostname(hostname) // update service records await cbPromise(cb => { @@ -61,7 +47,7 @@ export async function addService (hostname, psaDoc = null) { }) // remove any existing links - await cbPromise(cb => db.run(`DELETE FROM links WHERE serviceHostname = ?`, [hostname], cb)) + await cbPromise(cb => db.run(`DELETE FROM links WHERE hostname = ?`, [hostname], cb)) // add new links if (psaDoc && psaDoc.links && Array.isArray(psaDoc.links)) { @@ -80,7 +66,7 @@ export async function addService (hostname, psaDoc = null) { var {rel, href, title} = link title = typeof title === 'string' ? title : '' db.run( - `INSERT INTO links (serviceHostname, rel, href, title) VALUES (?, ?, ?, ?)`, + `INSERT INTO links (hostname, rel, href, title) VALUES (?, ?, ?, ?)`, [hostname, rel, href, title], cb ) @@ -91,7 +77,7 @@ export async function addService (hostname, psaDoc = null) { export async function removeService (hostname) { await setupPromise assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = getHostname(hostname) + hostname = toHostname(hostname) await cbPromise(cb => db.run(`DELETE FROM services WHERE hostname = ?`, [hostname], cb)) } @@ -100,21 +86,21 @@ export async function addAccount (hostname, {username, password}) { assert(hostname && typeof hostname === 'string', 'Hostname must be a string') assert(username && typeof username === 'string', 'Username must be a string') assert(password && typeof password === 'string', 'Password must be a string') - hostname = getHostname(hostname) + hostname = toHostname(hostname) // delete existing account - await cbPromise(cb => db.run(`DELETE FROM accounts WHERE serviceHostname = ? AND username = ?`, [hostname, username], cb)) + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE hostname = ? AND username = ?`, [hostname, username], cb)) // add new account - await cbPromise(cb => db.run(`INSERT INTO accounts (serviceHostname, username, password) VALUES (?, ?, ?)`, [hostname, username, password], cb)) + await cbPromise(cb => db.run(`INSERT INTO accounts (hostname, username, password) VALUES (?, ?, ?)`, [hostname, username, password], cb)) } export async function removeAccount (hostname, username) { await setupPromise assert(hostname && typeof hostname === 'string', 'Hostname must be a string') assert(username && typeof username === 'string', 'Username must be a string') - hostname = getHostname(hostname) - await cbPromise(cb => db.run(`DELETE FROM accounts WHERE serviceHostname = ? AND username = ?`, [hostname, username], cb)) + hostname = toHostname(hostname) + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE hostname = ? AND username = ?`, [hostname, username], cb)) } export async function getService (hostname) { @@ -124,15 +110,15 @@ export async function getService (hostname) { export async function getAccount (hostname, username) { await setupPromise - hostname = getHostname(hostname) - var query = 'SELECT username, password, serviceHostname FROM accounts WHERE serviceHostname = ? AND username = ?' + hostname = toHostname(hostname) + var query = 'SELECT username, password, hostname FROM accounts WHERE hostname = ? AND username = ?' return cbPromise(cb => db.get(query, [hostname, username], cb)) } export async function listServices ({hostname} = {}) { await setupPromise if (hostname) { - hostname = getHostname(hostname) + hostname = toHostname(hostname) } // construct query @@ -161,23 +147,23 @@ export async function listServices ({hostname} = {}) { return _keyBy(services, 'hostname') // return as an object } -export async function listAccounts ({rel} = {}) { +export async function listAccounts ({api} = {}) { await setupPromise // construct query var join = '' var where = ['1=1'] var params = [] - if (rel) { - where.push('links.rel=?') - params.push(rel) - join = 'LEFT JOIN links ON links.serviceHostname = accounts.serviceHostname' + if (api) { + where.push('links.rel = ?') + params.push(api) + join = 'LEFT JOIN links ON links.hostname = accounts.hostname' } where = where.join(' AND ') // run query var query = ` - SELECT accounts.username, accounts.serviceHostname + SELECT accounts.username, accounts.hostname FROM accounts ${join} WHERE ${where} @@ -187,25 +173,21 @@ export async function listAccounts ({rel} = {}) { export async function listServiceLinks (hostname) { await setupPromise - hostname = getHostname(hostname) - var query = 'SELECT rel, title, href FROM links WHERE serviceHostname = ?' + hostname = toHostname(hostname) + var query = 'SELECT rel, title, href FROM links WHERE hostname = ?' return cbPromise(cb => db.all(query, [hostname], cb)) } export async function listServiceAccounts (hostname) { await setupPromise - hostname = getHostname(hostname) - var query = 'SELECT username FROM accounts WHERE serviceHostname = ?' + hostname = toHostname(hostname) + var query = 'SELECT username FROM accounts WHERE hostname = ?' return cbPromise(cb => db.all(query, [hostname], cb)) } // internal methods // = -function getHostname (url) { - return parseURL(url).host || url -} - migrations = [ // version 1 function (cb) { @@ -217,20 +199,20 @@ migrations = [ createdAt INTEGER ); CREATE TABLE accounts ( - serviceHostname TEXT, + hostname TEXT, username TEXT, password TEXT, createdAt INTEGER, - FOREIGN KEY (serviceHostname) REFERENCES services (hostname) ON DELETE CASCADE + FOREIGN KEY (hostname) REFERENCES services (hostname) ON DELETE CASCADE ); CREATE TABLE links ( - serviceHostname TEXT, + hostname TEXT, rel TEXT, title TEXT, href TEXT, - FOREIGN KEY (serviceHostname) REFERENCES services (hostname) ON DELETE CASCADE + FOREIGN KEY (hostname) REFERENCES services (hostname) ON DELETE CASCADE ); PRAGMA user_version = 1; diff --git a/app/background-process/services.js b/app/background-process/services.js new file mode 100644 index 0000000000..6bd66cabb9 --- /dev/null +++ b/app/background-process/services.js @@ -0,0 +1,133 @@ +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, toHostname, getAPIPathname} from '../lib/bg/services' +import {REL_ACCOUNT_API} from '../lib/const' + +// globals +// = + +var psaDocs = {} +var sessions = {} + +// exported api +// = + +export async function fetchPSADoc (hostname, {noCache} = {}) { + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + hostname = toHostname(hostname) + + // check cache + if (!noCache && hostname in psaDocs) { + return psaDocs[hostname] + } + + // do fetch + var psaDocResponse = await request({ + hostname, + path: '/.well-known/psa' + }) + if (psaDocResponse.body && typeof psaDocResponse.body == 'object') { + psaDocs[hostname] = psaDocResponse.body + return psaDocResponse.body + } + throw new Error('Invalid PSA service description document') +} + +export async function makeAPIRequest ({hostname, api, username, method, path, headers, body}) { + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + hostname = toHostname(hostname) + + // get params + var session = username ? (await getOrCreateSession(hostname, username)) : undefined + var psaDoc = await fetchPSADoc(hostname) + var apiPath = getAPIPathname(psaDoc, api) + path = path ? joinPaths(apiPath, path) : apiPath + + // make request + return request({hostname, path, method, headers, session}, body) +} + +export async function registerHashbase (body) { + return request({ + hostname: 'hashbase.io', + path: '/v2/accounts/register', + method: 'POST' + }, body) +} + +export async function login (hostname, username, password) { + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + assert(password && typeof password === 'string', 'Password must be a string') + hostname = toHostname(hostname) + + // make the login request + var res = await makeAPIRequest({ + hostname, + api: REL_ACCOUNT_API, + method: 'POST', + path: '/login', + body: {username, password} + }) + + // store the session + if (res.body && res.body.sessionToken) { + _set(sessions, [hostname, username], res.body.sessionToken) + } + + return res +} + +export async function logout (hostname, username) { + assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + assert(username && typeof username === 'string', 'Username must be a string') + hostname = toHostname(hostname) + + // make the login request + var res = await makeAPIRequest({ + hostname, + api: REL_ACCOUNT_API, + username, + method: 'POST', + path: '/logout' + }) + + // clear the session + _set(sessions, [hostname, username], null) + + return res +} + +// TODO needed? +// export async function getAccount (hostname, username) { +// assert(hostname && typeof hostname === 'string', 'Hostname must be a string') +// assert(username && typeof username === 'string', 'Username must be a string') +// return makeAPIRequest({ +// hostname, +// api: REL_ACCOUNT_API, +// username, +// path: '/account' +// }) +// } + +// internal methods +// = + +async function getOrCreateSession (hostname, username) { + // check cache + var session = _get(sessions, [hostname, username]) + if (session) return session + + // lookup account credentials + var creds = await servicesDb.getAccount(hostname, username) + if (!creds) { + throw new Error('Account not found') + } + + // do login + var res = await login(hostname, 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 7850667f38..0e759ce563 100644 --- a/app/background-process/web-apis.js +++ b/app/background-process/web-apis.js @@ -15,11 +15,11 @@ import servicesManifest from '../lib/api-manifests/internal/services' 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' import {WEBAPI as beakerBrowserAPI} from './browser' -import {WEBAPI as servicesAPI} from './dbs/services' // external manifests import datArchiveManifest from '../lib/api-manifests/external/dat-archive' 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/lib/api-manifests/internal/services.js b/app/lib/api-manifests/internal/services.js index 21c62756cd..edfcf380aa 100644 --- a/app/lib/api-manifests/internal/services.js +++ b/app/lib/api-manifests/internal/services.js @@ -1,4 +1,12 @@ export default { + fetchPSADoc: 'promise', + makeAPIRequest: 'promise', + + registerHashbase: 'promise', + + login: 'promise', + logout: 'promise', + addService: 'promise', removeService: 'promise', addAccount: 'promise', diff --git a/app/lib/bg/services.js b/app/lib/bg/services.js new file mode 100644 index 0000000000..0d8075a6b0 --- /dev/null +++ b/app/lib/bg/services.js @@ -0,0 +1,111 @@ +import assert from 'assert' +import http from 'http' +import https from 'https' +import {parse as parseURL} from 'url' + +// exported api + +export function toHostname (url = '') { + if (!url) return url + if (url.indexOf('://') === -1) return url + return parseURL(url).host || url +} + +export function request (opts, body = undefined) { + return new Promise((resolve, reject) => { + var reqOpts = {headers: {}} + + // parse URL + var urlp + if (opts.hostname.indexOf('://') === -1) { + let [hostname, port] = opts.hostname.split(':') + let protocol = process.env.NODE_ENV === 'test' ? 'http:' : 'https:' + if (port) port = +port + urlp = {protocol, hostname, port} + } else { + urlp = parseURL(opts.hostname) + } + 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 + 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) {} + } + + // reject / resolve + if (res.statusCode >= 400) { + var err = new Error(resBody && resBody.message ? resBody.message : 'Request failed') + err.statusCode = res.statusCode + err.headers = res.headers + err.body = resBody + reject(err) + } else { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: resBody + }) + } + }) + }) + req.on('error', err => reject(new Error(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 f499366c8c..7f747158ca 100644 --- a/app/lib/const.js +++ b/app/lib/const.js @@ -63,3 +63,7 @@ export const STANDARD_ARCHIVE_TYPES = [ export const URL_HASHBASE_SIGNUP = 'https://hashbase.io/register' 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..9d2b278fcb --- /dev/null +++ b/app/lib/fg/dedicated-peers.js @@ -0,0 +1,62 @@ +import {join as joinPaths} from 'path' +import {DAT_HASH_REGEX, REL_DATS_API} from '../const' + +// exported api +// = + +export function listAccounts () { + return beaker.service.listAccounts({api: REL_DATS_API}) +} + +export async function getAllPins (datUrl) { + var accounts = await listAccounts() + return Promise.all(accounts.map(account => getPinAt(account, datUrl))) +} + +export function getPinAt (account, datUrl) { + return beaker.services.makeAPIRequest({ + hostname: account.hostname, + username: account.username, + api: REL_DATS_API, + method: 'GET', + path: joinPaths('item', urlToKey(datUrl)) + }) +} + +export function pinDat (account, datUrl, datName) { + return beaker.services.makeAPIRequest({ + hostname: account.hostname, + 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({ + hostname: account.hostname, + username: account.username, + api: REL_DATS_API, + method: 'POST', + path: '/remove', + body: { + url: datUrl + } + }) +} + +// internal methods +// = + +function urlToKey (url) { + var match = (url || '').match(DAT_HASH_REGEX) + if (match) { + return match[0].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 3dbb38b412..85f954474b 100644 --- a/app/lib/web-apis/beaker.js +++ b/app/lib/web-apis/beaker.js @@ -135,6 +135,11 @@ if (window.location.protocol === 'beaker:') { // 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 diff --git a/app/package.json b/app/package.json index 2d4b96d1cb..086a817883 100644 --- a/app/package.json +++ b/app/package.json @@ -56,6 +56,7 @@ "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/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 index fdc7013194..0d70b98e05 100644 --- a/tests/services-web-api-test.js +++ b/tests/services-web-api-test.js @@ -2,11 +2,32 @@ 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'], @@ -17,10 +38,26 @@ const app = browserdriver.start({ } }) 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 => { @@ -185,18 +222,18 @@ test('manage accounts', async t => { beaker.services.listAccounts() `) t.deepEqual(res, [ - {serviceHostname: 'foo.com', username: 'alice'}, - {serviceHostname: 'foo.com', username: 'bob'}, - {serviceHostname: 'baz.com', username: 'alice'} + {hostname: 'foo.com', username: 'alice'}, + {hostname: 'foo.com', username: 'bob'}, + {hostname: 'baz.com', username: 'alice'} ]) // list accounts (rel filter) var res = await app.executeJavascript(` - beaker.services.listAccounts({rel: 'http://api-spec.com/clock'}) + beaker.services.listAccounts({api: 'http://api-spec.com/clock'}) `) t.deepEqual(res, [ - {serviceHostname: 'foo.com', username: 'alice'}, - {serviceHostname: 'foo.com', username: 'bob'} + {hostname: 'foo.com', username: 'alice'}, + {hostname: 'foo.com', username: 'bob'} ]) // get account @@ -204,7 +241,7 @@ test('manage accounts', async t => { beaker.services.getAccount('foo.com', 'alice') `) t.deepEqual(res, { - serviceHostname: 'foo.com', + hostname: 'foo.com', username: 'alice', password: 'hunter2' }) @@ -238,7 +275,7 @@ test('manage accounts', async t => { beaker.services.getAccount('foo.com', 'alice') `) t.deepEqual(res, { - serviceHostname: 'foo.com', + hostname: 'foo.com', username: 'alice', password: 'hunter3' }) @@ -254,6 +291,108 @@ test('manage accounts', async t => { t.falsy(res) }) +test('fetchPSADoc', async t => { + // test valid host + var res = await app.executeJavascript(` + beaker.services.fetchPSADoc('localhost:8888') + `) + t.deepEqual(res, LOCALHOST_PSA) + + // include protocol + var res = await app.executeJavascript(` + beaker.services.fetchPSADoc('http://localhost:8888') + `) + t.deepEqual(res, LOCALHOST_PSA) + + // test invalid host + await t.throws(app.executeJavascript(` + beaker.services.fetchPSADoc('localhost') + `)) +}) + +test('login / logout / makeAPIRequest', async t => { + // test without session + await t.throws(app.executeJavascript(` + beaker.services.makeAPIRequest({ + hostname: 'localhost:8888', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `)) + await t.throws(app.executeJavascript(` + beaker.services.makeAPIRequest({ + hostname: 'localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `)) + + // fail login + await t.throws(app.executeJavascript(` + beaker.services.login('localhost:8888', 'admin', 'wrongpassword') + `)) + + // login + var res = await app.executeJavascript(` + beaker.services.login('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({ + hostname: '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('localhost:8888', 'admin') + `) + t.is(res.statusCode, 200) + + // test without session + await t.throws(app.executeJavascript(` + beaker.services.makeAPIRequest({ + hostname: 'localhost:8888', + username: 'admin', + api: '${REL_ACCOUNT_API}', + path: '/account' + }) + `)) +}) + +test('login with stored credentials', async t => { + // add service + var res = await app.executeJavascript(` + beaker.services.addService('localhost:8888', ${JSON.stringify(LOCALHOST_PSA)}) + `) + t.falsy(res) + + // add account + var res = await app.executeJavascript(` + beaker.services.addAccount('localhost:8888', {username: 'admin', password: 'hunter2'}) + `) + t.falsy(res) + + // get account data (no prior login) + var res = await app.executeJavascript(` + beaker.services.makeAPIRequest({ + hostname: '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 From 02216099d6cf1cc2e6ee8f81e554d3f556eebdb4 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 11 May 2018 13:44:37 -0500 Subject: [PATCH 06/10] Track services by origin instead of by hostname --- app/background-process/dbs/services.js | 110 +++++++++--------- app/background-process/services.js | 87 +++++++------- .../com/library-share-dropdown.js | 55 ++++++--- .../com/settings/dedicated-peers.js | 104 ++++++++++++----- app/lib/bg/services.js | 58 +++++---- app/lib/const.js | 3 +- app/lib/fg/dedicated-peers.js | 11 +- app/stylesheets/components/inputs.less | 8 ++ tests/services-web-api-test.js | 105 +++++++++-------- 9 files changed, 316 insertions(+), 225 deletions(-) diff --git a/app/background-process/dbs/services.js b/app/background-process/dbs/services.js index 999ce4f787..a12bc13969 100644 --- a/app/background-process/dbs/services.js +++ b/app/background-process/dbs/services.js @@ -5,7 +5,7 @@ import assert from 'assert' import _keyBy from 'lodash.keyby' import {parse as parseURL} from 'url' import { cbPromise } from '../../lib/functions' -import {toHostname} from '../../lib/bg/services' +import {toOrigin} from '../../lib/bg/services' import { setupSqliteDB } from '../../lib/bg/db' // globals @@ -24,10 +24,10 @@ export function setup () { setupPromise = setupSqliteDB(db, {migrations}, '[SERVICES]') } -export async function addService (hostname, psaDoc = null) { +export async function addService (origin, psaDoc = null) { await setupPromise - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = toHostname(hostname) + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) // update service records await cbPromise(cb => { @@ -35,19 +35,19 @@ export async function addService (hostname, psaDoc = null) { var description = psaDoc && typeof psaDoc.description === 'string' ? psaDoc.description : '' var createdAt = Date.now() db.run( - `UPDATE services SET title=?, description=? WHERE hostname=?`, - [title, description, hostname], + `UPDATE services SET title=?, description=? WHERE origin=?`, + [title, description, origin], cb ) db.run( - `INSERT OR IGNORE INTO services (hostname, title, description, createdAt) VALUES (?, ?, ?, ?)`, - [hostname, title, description, createdAt], + `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 hostname = ?`, [hostname], cb)) + await cbPromise(cb => db.run(`DELETE FROM links WHERE origin = ?`, [origin], cb)) // add new links if (psaDoc && psaDoc.links && Array.isArray(psaDoc.links)) { @@ -66,73 +66,73 @@ export async function addService (hostname, psaDoc = null) { var {rel, href, title} = link title = typeof title === 'string' ? title : '' db.run( - `INSERT INTO links (hostname, rel, href, title) VALUES (?, ?, ?, ?)`, - [hostname, rel, href, title], + `INSERT INTO links (origin, rel, href, title) VALUES (?, ?, ?, ?)`, + [origin, rel, href, title], cb ) }))) } } -export async function removeService (hostname) { +export async function removeService (origin) { await setupPromise - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = toHostname(hostname) - await cbPromise(cb => db.run(`DELETE FROM services WHERE hostname = ?`, [hostname], cb)) + 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 (hostname, {username, password}) { +export async function addAccount (origin, username, password) { await setupPromise - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + 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') - hostname = toHostname(hostname) + origin = toOrigin(origin) // delete existing account - await cbPromise(cb => db.run(`DELETE FROM accounts WHERE hostname = ? AND username = ?`, [hostname, username], cb)) + 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 (hostname, username, password) VALUES (?, ?, ?)`, [hostname, username, password], cb)) + await cbPromise(cb => db.run(`INSERT INTO accounts (origin, username, password) VALUES (?, ?, ?)`, [origin, username, password], cb)) } -export async function removeAccount (hostname, username) { +export async function removeAccount (origin, username) { await setupPromise - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') + assert(origin && typeof origin === 'string', 'Origin must be a string') assert(username && typeof username === 'string', 'Username must be a string') - hostname = toHostname(hostname) - await cbPromise(cb => db.run(`DELETE FROM accounts WHERE hostname = ? AND username = ?`, [hostname, username], cb)) + origin = toOrigin(origin) + await cbPromise(cb => db.run(`DELETE FROM accounts WHERE origin = ? AND username = ?`, [origin, username], cb)) } -export async function getService (hostname) { - var services = await listServices({hostname}) +export async function getService (origin) { + var services = await listServices({origin}) return Object.values(services)[0] } -export async function getAccount (hostname, username) { +export async function getAccount (origin, username) { await setupPromise - hostname = toHostname(hostname) - var query = 'SELECT username, password, hostname FROM accounts WHERE hostname = ? AND username = ?' - return cbPromise(cb => db.get(query, [hostname, username], cb)) + 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 ({hostname} = {}) { +export async function listServices ({origin} = {}) { await setupPromise - if (hostname) { - hostname = toHostname(hostname) + if (origin) { + origin = toOrigin(origin) } // construct query var where = ['1=1'] var params = [] - if (hostname) { - where.push('hostname = ?') - params.push(hostname) + if (origin) { + where.push('origin = ?') + params.push(origin) } where = where.join(' AND ') // run query var query = ` - SELECT hostname, title, description, createdAt + SELECT origin, title, description, createdAt FROM services WHERE ${where} ` @@ -140,11 +140,11 @@ export async function listServices ({hostname} = {}) { // get links on each await Promise.all(services.map(async (service) => { - service.links = await listServiceLinks(service.hostname) - service.accounts = await listServiceAccounts(service.hostname) + service.links = await listServiceLinks(service.origin) + service.accounts = await listServiceAccounts(service.origin) })) - return _keyBy(services, 'hostname') // return as an object + return _keyBy(services, 'origin') // return as an object } export async function listAccounts ({api} = {}) { @@ -157,13 +157,13 @@ export async function listAccounts ({api} = {}) { if (api) { where.push('links.rel = ?') params.push(api) - join = 'LEFT JOIN links ON links.hostname = accounts.hostname' + join = 'LEFT JOIN links ON links.origin = accounts.origin' } where = where.join(' AND ') // run query var query = ` - SELECT accounts.username, accounts.hostname + SELECT accounts.username, accounts.origin FROM accounts ${join} WHERE ${where} @@ -171,18 +171,18 @@ export async function listAccounts ({api} = {}) { return cbPromise(cb => db.all(query, params, cb)) } -export async function listServiceLinks (hostname) { +export async function listServiceLinks (origin) { await setupPromise - hostname = toHostname(hostname) - var query = 'SELECT rel, title, href FROM links WHERE hostname = ?' - return cbPromise(cb => db.all(query, [hostname], cb)) + 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 (hostname) { +export async function listServiceAccounts (origin) { await setupPromise - hostname = toHostname(hostname) - var query = 'SELECT username FROM accounts WHERE hostname = ?' - return cbPromise(cb => db.all(query, [hostname], cb)) + origin = toOrigin(origin) + var query = 'SELECT username FROM accounts WHERE origin = ?' + return cbPromise(cb => db.all(query, [origin], cb)) } // internal methods @@ -193,26 +193,26 @@ migrations = [ function (cb) { db.exec(` CREATE TABLE services ( - hostname TEXT PRIMARY KEY, + origin TEXT PRIMARY KEY, title TEXT, description TEXT, createdAt INTEGER ); CREATE TABLE accounts ( - hostname TEXT, + origin TEXT, username TEXT, password TEXT, createdAt INTEGER, - FOREIGN KEY (hostname) REFERENCES services (hostname) ON DELETE CASCADE + FOREIGN KEY (origin) REFERENCES services (origin) ON DELETE CASCADE ); CREATE TABLE links ( - hostname TEXT, + origin TEXT, rel TEXT, title TEXT, href TEXT, - FOREIGN KEY (hostname) REFERENCES services (hostname) ON DELETE CASCADE + FOREIGN KEY (origin) REFERENCES services (origin) ON DELETE CASCADE ); PRAGMA user_version = 1; diff --git a/app/background-process/services.js b/app/background-process/services.js index 6bd66cabb9..58429a0b2b 100644 --- a/app/background-process/services.js +++ b/app/background-process/services.js @@ -3,8 +3,8 @@ import {join as joinPaths} from 'path' import _get from 'lodash.get' import _set from 'lodash.set' import * as servicesDb from './dbs/services' -import {request, toHostname, getAPIPathname} from '../lib/bg/services' -import {REL_ACCOUNT_API} from '../lib/const' +import {request, toOrigin, getAPIPathname} from '../lib/bg/services' +import {URL_HASHBASE, REL_ACCOUNT_API} from '../lib/const' // globals // = @@ -15,58 +15,62 @@ var sessions = {} // exported api // = -export async function fetchPSADoc (hostname, {noCache} = {}) { - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = toHostname(hostname) +export async function fetchPSADoc (origin, {noCache} = {}) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) // check cache - if (!noCache && hostname in psaDocs) { - return psaDocs[hostname] + if (!noCache && origin in psaDocs) { + return {success: true, statusCode: 200, headers: {}, body: psaDocs[origin]} } // do fetch var psaDocResponse = await request({ - hostname, + origin, path: '/.well-known/psa' }) - if (psaDocResponse.body && typeof psaDocResponse.body == 'object') { - psaDocs[hostname] = psaDocResponse.body - return psaDocResponse.body + if (psaDocResponse.success && psaDocResponse.body && typeof psaDocResponse.body == 'object') { + let psaDoc = psaDocResponse.body + psaDocs[origin] = psaDoc + await servicesDb.addService(origin, psaDoc) } - throw new Error('Invalid PSA service description document') + return psaDocResponse } -export async function makeAPIRequest ({hostname, api, username, method, path, headers, body}) { - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - hostname = toHostname(hostname) +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 - var session = username ? (await getOrCreateSession(hostname, username)) : undefined - var psaDoc = await fetchPSADoc(hostname) - var apiPath = getAPIPathname(psaDoc, api) + try { + var session = username ? (await getOrCreateSession(origin, username)) : undefined + } catch (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 - return request({hostname, path, method, headers, session}, body) + return request({origin, path, method, headers, session}, body) } export async function registerHashbase (body) { return request({ - hostname: 'hashbase.io', + origin: URL_HASHBASE, path: '/v2/accounts/register', method: 'POST' }, body) } -export async function login (hostname, username, password) { - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - assert(username && typeof username === 'string', 'Username must be a string') - assert(password && typeof password === 'string', 'Password must be a string') - hostname = toHostname(hostname) +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({ - hostname, + origin, api: REL_ACCOUNT_API, method: 'POST', path: '/login', @@ -74,21 +78,20 @@ export async function login (hostname, username, password) { }) // store the session - if (res.body && res.body.sessionToken) { - _set(sessions, [hostname, username], res.body.sessionToken) + if (res.success && res.body && res.body.sessionToken) { + _set(sessions, [origin, username], res.body.sessionToken) } return res } -export async function logout (hostname, username) { - assert(hostname && typeof hostname === 'string', 'Hostname must be a string') - assert(username && typeof username === 'string', 'Username must be a string') - hostname = toHostname(hostname) +export async function logout (origin, username) { + assert(origin && typeof origin === 'string', 'Origin must be a string') + origin = toOrigin(origin) - // make the login request + // make the logout request var res = await makeAPIRequest({ - hostname, + origin, api: REL_ACCOUNT_API, username, method: 'POST', @@ -96,17 +99,17 @@ export async function logout (hostname, username) { }) // clear the session - _set(sessions, [hostname, username], null) + _set(sessions, [origin, username], null) return res } // TODO needed? -// export async function getAccount (hostname, username) { -// assert(hostname && typeof hostname === 'string', 'Hostname must be a string') +// 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({ -// hostname, +// origin, // api: REL_ACCOUNT_API, // username, // path: '/account' @@ -116,18 +119,18 @@ export async function logout (hostname, username) { // internal methods // = -async function getOrCreateSession (hostname, username) { +async function getOrCreateSession (origin, username) { // check cache - var session = _get(sessions, [hostname, username]) + var session = _get(sessions, [origin, username]) if (session) return session // lookup account credentials - var creds = await servicesDb.getAccount(hostname, username) + var creds = await servicesDb.getAccount(origin, username) if (!creds) { throw new Error('Account not found') } // do login - var res = await login(hostname, creds.username, creds.password) + var res = await login(origin, creds.username, creds.password) return res.body.sessionToken } diff --git a/app/builtin-pages/com/library-share-dropdown.js b/app/builtin-pages/com/library-share-dropdown.js index 3dbd4cfb9f..8f6e360b7c 100644 --- a/app/builtin-pages/com/library-share-dropdown.js +++ b/app/builtin-pages/com/library-share-dropdown.js @@ -2,6 +2,8 @@ import * as yo from 'yo-yo' import toggleable from './toggleable' +import * as dedicatedPeers from '../../lib/fg/dedicated-peers' +import {URL_DEDICATED_PEER_GUIDE} from '../../lib/const' // exported api // = @@ -25,48 +27,65 @@ export default function render (archive) { function renderInner (archive) { var el = yo`
-
- ${renderUrl({label: 'Raw URL', url: archive.url})} - ${renderUrl({label: 'hashbase.io', url: 'dat://foo-pfrazee.hashbase.io'})} + ${renderUrls(archive)} + ${renderPeers(archive)} +
` + + dedicatedPeers.getAllPins(archive.url).then(({accounts, urls}) => { + yo.update(el.querySelector('.urls'), renderUrls(archive, urls)) + yo.update(el.querySelector('.peers'), renderPeers(archive, accounts)) + }, console.error) + + return el +} + +function renderUrls (archive, urls = []) { + return yo` +
+ ${renderUrl({label: 'Raw URL', url: archive.url})} + ${urls.map(url => renderUrl({url}))} +
` +} + +function renderPeers (archive, accounts = []) { + return yo` +
+
+ Share with a dedicated peer +
-
-
Share with a peer
- ${renderPeer({hostname: 'hashbase.io', isShared: true, name: 'foo', urls: ['dat://foo-pfrazee.hashbase.io']})} - ${renderPeer({hostname: 'taravancil.com', isShared: false})} -
-
- - Add a peer -
+ ${accounts.map(account => renderPeer({origin: account.origin, isShared: false}))} +
+
+ + Add a peer
` - - return el } function renderUrl ({label, url}) { return yo`
- ${label} + ${label || ''} Copy URL
` } -function renderPeer ({hostname, isShared, name}) { +function renderPeer ({origin, isShared, name}) { return yo`
${isShared ? yo`` : yo``} - ${hostname} + ${origin}
-
Share with ${hostname}
+
Share with ${origin}
diff --git a/app/builtin-pages/com/settings/dedicated-peers.js b/app/builtin-pages/com/settings/dedicated-peers.js index 78a70a6de4..7c517bbdd7 100644 --- a/app/builtin-pages/com/settings/dedicated-peers.js +++ b/app/builtin-pages/com/settings/dedicated-peers.js @@ -1,9 +1,11 @@ import yo from 'yo-yo' +import * as dedicatedPeers from '../../../lib/fg/dedicated-peers' // globals // = -var formAction = '' +var accounts +var formAction = false var registerFields = [ {name: 'username', label: 'Username', value: ''}, {name: 'email', label: 'Email address', value: ''}, @@ -15,19 +17,23 @@ var signinFields = [ {name: 'username', label: 'Username', value: ''}, {name: 'password', label: 'Password', value: '', type: 'password'}, ] +var errors = null // exported api // = -export default function renderDedicatedPeers () { +export default function renderDedicatedPeers (dedicatedPeerAccounts) { + if (!accounts) { + loadAccounts() + } + return yo`

Dedicated Peers

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

- ${renderPeer({name: 'hashbase.io', user: 'pfrazee'})} - ${renderPeer({name: 'paulfrazee.com', user: 'admin'})} + ${accounts ? accounts.map(renderPeer) : ''} ${formAction ? renderForm() @@ -35,32 +41,21 @@ export default function renderDedicatedPeers () {
`} - - ${''/*

- Sign up at Hashbase.io - or read our self-hosting guide (advanced). -

*/}
- ${''/*
-

Advanced users

-

- You can run your own dedicated peer using Homebase. -

-
*/}
` } // rendering // = -function renderPeer ({name, user}) { +function renderPeer ({origin, username}) { return yo`
- ${name} - ${user} + ${origin} + ${username} - +
` } @@ -68,53 +63,56 @@ function renderPeer ({name, user}) { function renderForm () { if (!formAction) return '' return yo` - +

${formAction === 'register' ? renderRegisterForm() : renderSigninForm()} - ` +
` } function renderRegisterForm () { return yo` -
+
${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}
` : ''}
` } @@ -125,8 +123,15 @@ 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 updatePage() // highlight first field @@ -140,3 +145,48 @@ function onChangeAddPeerAction (e, value) { function onChangeInput (e, field) { field.value = e.currentTarget.value } + +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/lib/bg/services.js b/app/lib/bg/services.js index 0d8075a6b0..0e7eb84187 100644 --- a/app/lib/bg/services.js +++ b/app/lib/bg/services.js @@ -1,33 +1,44 @@ import assert from 'assert' import http from 'http' import https from 'https' -import {parse as parseURL} from 'url' +import {URL, parse as parseURL} from 'url' // exported api -export function toHostname (url = '') { +export function toOrigin (url = '') { if (!url) return url - if (url.indexOf('://') === -1) return url - return parseURL(url).host || 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.hostname.indexOf('://') === -1) { - let [hostname, port] = opts.hostname.split(':') - let protocol = process.env.NODE_ENV === 'test' ? 'http:' : 'https:' + 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.hostname) + urlp = parseURL(opts.origin) } reqOpts.protocol = urlp.protocol reqOpts.hostname = urlp.hostname - reqOpts.path = opts.path + reqOpts.path = opts.path || '/' if (urlp.port) { reqOpts.port = urlp.port } @@ -67,23 +78,22 @@ export function request (opts, body = undefined) { } catch (e) {} } - // reject / resolve - if (res.statusCode >= 400) { - var err = new Error(resBody && resBody.message ? resBody.message : 'Request failed') - err.statusCode = res.statusCode - err.headers = res.headers - err.body = resBody - reject(err) - } else { - resolve({ - statusCode: res.statusCode, - headers: res.headers, - body: resBody - }) - } + // resolve + var statusCode = +res.statusCode + resolve({ + success: statusCode >= 200 && statusCode < 300, + statusCode, + headers: res.headers, + body: resBody + }) }) }) - req.on('error', err => reject(new Error(err.toString()))) + req.on('error', err => resolve({ + success: false, + statusCode: 0, + headers: {}, + body: {message: err.toString()} + })) if (body) { req.write(body) } diff --git a/app/lib/const.js b/app/lib/const.js index 7f747158ca..50e72a87b4 100644 --- a/app/lib/const.js +++ b/app/lib/const.js @@ -60,7 +60,8 @@ export const STANDARD_ARCHIVE_TYPES = [ ] // URLs used in various places in the UI -export const URL_HASHBASE_SIGNUP = 'https://hashbase.io/register' +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 diff --git a/app/lib/fg/dedicated-peers.js b/app/lib/fg/dedicated-peers.js index 9d2b278fcb..c466178c38 100644 --- a/app/lib/fg/dedicated-peers.js +++ b/app/lib/fg/dedicated-peers.js @@ -5,17 +5,18 @@ import {DAT_HASH_REGEX, REL_DATS_API} from '../const' // = export function listAccounts () { - return beaker.service.listAccounts({api: REL_DATS_API}) + return beaker.services.listAccounts({api: REL_DATS_API}) } export async function getAllPins (datUrl) { var accounts = await listAccounts() - return Promise.all(accounts.map(account => getPinAt(account, datUrl))) + var pins = await Promise.all(accounts.map(account => getPinAt(account, datUrl))) + return {accounts, pins} } export function getPinAt (account, datUrl) { return beaker.services.makeAPIRequest({ - hostname: account.hostname, + origin: account.origin, username: account.username, api: REL_DATS_API, method: 'GET', @@ -25,7 +26,7 @@ export function getPinAt (account, datUrl) { export function pinDat (account, datUrl, datName) { return beaker.services.makeAPIRequest({ - hostname: account.hostname, + origin: account.origin, username: account.username, api: REL_DATS_API, method: 'POST', @@ -39,7 +40,7 @@ export function pinDat (account, datUrl, datName) { export function unpinDat (account, datUrl) { return beaker.services.makeAPIRequest({ - hostname: account.hostname, + origin: account.origin, username: account.username, api: REL_DATS_API, method: 'POST', 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/services-web-api-test.js b/tests/services-web-api-test.js index 0d70b98e05..9641aa87aa 100644 --- a/tests/services-web-api-test.js +++ b/tests/services-web-api-test.js @@ -79,7 +79,7 @@ test('manage services', async t => { `) t.falsy(res) var res = await app.executeJavascript(` - beaker.services.addService('https://bar.com', { + beaker.services.addService('http://bar.com', { title: 'Bar Service', description: 'It is bar' }) @@ -100,23 +100,23 @@ test('manage services', async t => { var res = await app.executeJavascript(` beaker.services.listServices() `) - massageServiceObj(res['foo.com']) - massageServiceObj(res['bar.com']) - massageServiceObj(res['baz.com']) + massageServiceObj(res['https://foo.com']) + massageServiceObj(res['http://bar.com']) + massageServiceObj(res['https://baz.com']) t.deepEqual(res, { - 'bar.com': { + 'http://bar.com': { accounts: [], createdAt: 'number', description: 'It is bar', - hostname: 'bar.com', + origin: 'http://bar.com', links: [], title: 'Bar Service' }, - 'baz.com': { + 'https://baz.com': { accounts: [], createdAt: 'number', description: '', - hostname: 'baz.com', + origin: 'https://baz.com', links: [ {href: '/href', rel: 'a', title: 'Got links'}, {href: '/href', rel: 'b', title: 'Got links'}, @@ -124,11 +124,11 @@ test('manage services', async t => { ], title: '' }, - 'foo.com': { + 'https://foo.com': { accounts: [], createdAt: 'number', description: 'It is foo', - hostname: 'foo.com', + origin: 'https://foo.com', links: [ { href: '/v1/users', @@ -154,7 +154,7 @@ test('manage services', async t => { accounts: [], createdAt: 'number', description: '', - hostname: 'baz.com', + origin: 'https://baz.com', links: [ {href: '/href', rel: 'a', title: 'Got links'}, {href: '/href', rel: 'b', title: 'Got links'}, @@ -182,7 +182,7 @@ test('manage services', async t => { accounts: [], createdAt: 'number', description: '', - hostname: 'baz.com', + origin: 'https://baz.com', links: [ {href: '/href2', rel: 'c', title: 'Got links 2'}, {href: '/href2', rel: 'd', title: 'Got links 2'}, @@ -205,15 +205,15 @@ test('manage services', async t => { test('manage accounts', async t => { // add some accounts var res = await app.executeJavascript(` - beaker.services.addAccount('foo.com', {username: 'alice', password: 'hunter2'}) + beaker.services.addAccount('https://foo.com', 'alice', 'hunter2') `) t.falsy(res) var res = await app.executeJavascript(` - beaker.services.addAccount('foo.com', {username: 'bob', password: 'hunter2'}) + beaker.services.addAccount('foo.com', 'bob', 'hunter2') `) t.falsy(res) var res = await app.executeJavascript(` - beaker.services.addAccount('baz.com', {username: 'alice', password: 'hunter2'}) + beaker.services.addAccount('baz.com', 'alice', 'hunter2') `) t.falsy(res) @@ -222,9 +222,9 @@ test('manage accounts', async t => { beaker.services.listAccounts() `) t.deepEqual(res, [ - {hostname: 'foo.com', username: 'alice'}, - {hostname: 'foo.com', username: 'bob'}, - {hostname: 'baz.com', username: 'alice'} + {origin: 'https://foo.com', username: 'alice'}, + {origin: 'https://foo.com', username: 'bob'}, + {origin: 'https://baz.com', username: 'alice'} ]) // list accounts (rel filter) @@ -232,16 +232,16 @@ test('manage accounts', async t => { beaker.services.listAccounts({api: 'http://api-spec.com/clock'}) `) t.deepEqual(res, [ - {hostname: 'foo.com', username: 'alice'}, - {hostname: 'foo.com', username: 'bob'} + {origin: 'https://foo.com', username: 'alice'}, + {origin: 'https://foo.com', username: 'bob'} ]) // get account var res = await app.executeJavascript(` - beaker.services.getAccount('foo.com', 'alice') + beaker.services.getAccount('https://foo.com', 'alice') `) t.deepEqual(res, { - hostname: 'foo.com', + origin: 'https://foo.com', username: 'alice', password: 'hunter2' }) @@ -257,7 +257,7 @@ test('manage accounts', async t => { ], createdAt: 'number', description: '', - hostname: 'baz.com', + origin: 'https://baz.com', links: [ {href: '/href2', rel: 'c', title: 'Got links 2'}, {href: '/href2', rel: 'd', title: 'Got links 2'}, @@ -268,14 +268,14 @@ test('manage accounts', async t => { // overwrite account var res = await app.executeJavascript(` - beaker.services.addAccount('foo.com', {username: 'alice', password: 'hunter3'}) + beaker.services.addAccount('https://foo.com', 'alice', 'hunter3') `) t.falsy(res) var res = await app.executeJavascript(` - beaker.services.getAccount('foo.com', 'alice') + beaker.services.getAccount('https://foo.com', 'alice') `) t.deepEqual(res, { - hostname: 'foo.com', + origin: 'https://foo.com', username: 'alice', password: 'hunter3' }) @@ -293,49 +293,47 @@ test('manage accounts', async t => { test('fetchPSADoc', async t => { // test valid host - var res = await app.executeJavascript(` - beaker.services.fetchPSADoc('localhost:8888') - `) - t.deepEqual(res, LOCALHOST_PSA) - - // include protocol var res = await app.executeJavascript(` beaker.services.fetchPSADoc('http://localhost:8888') `) - t.deepEqual(res, LOCALHOST_PSA) + t.deepEqual(res.body, LOCALHOST_PSA) // test invalid host - await t.throws(app.executeJavascript(` + var res = await app.executeJavascript(` beaker.services.fetchPSADoc('localhost') - `)) + `) + t.falsy(res.success) }) test('login / logout / makeAPIRequest', async t => { // test without session - await t.throws(app.executeJavascript(` + var res = await app.executeJavascript(` beaker.services.makeAPIRequest({ - hostname: 'localhost:8888', + origin: 'http://localhost:8888', api: '${REL_ACCOUNT_API}', path: '/account' }) - `)) - await t.throws(app.executeJavascript(` + `) + t.falsy(res.success) + var res = await app.executeJavascript(` beaker.services.makeAPIRequest({ - hostname: 'localhost:8888', + origin: 'http://localhost:8888', username: 'admin', api: '${REL_ACCOUNT_API}', path: '/account' }) - `)) + `) + t.falsy(res.success) // fail login - await t.throws(app.executeJavascript(` - beaker.services.login('localhost:8888', 'admin', 'wrongpassword') - `)) + 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('localhost:8888', 'admin', 'hunter2') + beaker.services.login('http://localhost:8888', 'admin', 'hunter2') `) t.is(res.statusCode, 200) t.is(typeof res.body.sessionToken, 'string') @@ -343,7 +341,7 @@ test('login / logout / makeAPIRequest', async t => { // get account data var res = await app.executeJavascript(` beaker.services.makeAPIRequest({ - hostname: 'localhost:8888', + origin: 'http://localhost:8888', username: 'admin', api: '${REL_ACCOUNT_API}', path: '/account' @@ -353,38 +351,39 @@ test('login / logout / makeAPIRequest', async t => { // logout var res = await app.executeJavascript(` - beaker.services.logout('localhost:8888', 'admin') + beaker.services.logout('http://localhost:8888', 'admin') `) t.is(res.statusCode, 200) // test without session - await t.throws(app.executeJavascript(` + var res = await app.executeJavascript(` beaker.services.makeAPIRequest({ - hostname: 'localhost:8888', + 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('localhost:8888', ${JSON.stringify(LOCALHOST_PSA)}) + beaker.services.addService('http://localhost:8888', ${JSON.stringify(LOCALHOST_PSA)}) `) t.falsy(res) // add account var res = await app.executeJavascript(` - beaker.services.addAccount('localhost:8888', {username: 'admin', password: 'hunter2'}) + 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({ - hostname: 'localhost:8888', + origin: 'http://localhost:8888', username: 'admin', api: '${REL_ACCOUNT_API}', path: '/account' From 8dbee03b76585a921dc0a7bb38c0cc1a3925e584 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 11 May 2018 13:52:03 -0500 Subject: [PATCH 07/10] Ask for json responses in services api --- app/lib/bg/services.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/bg/services.js b/app/lib/bg/services.js index 0e7eb84187..cff8cbaa07 100644 --- a/app/lib/bg/services.js +++ b/app/lib/bg/services.js @@ -47,6 +47,7 @@ export function request (opts, body = undefined) { 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] From 75f0fb7802de0ad694160d4729a6e6706e30c66a Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 11 May 2018 14:06:13 -0500 Subject: [PATCH 08/10] Add service api-call logging --- app/background-process/debug-logger.js | 2 +- app/background-process/services.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 index 58429a0b2b..4b4f8d3347 100644 --- a/app/background-process/services.js +++ b/app/background-process/services.js @@ -5,10 +5,12 @@ 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 = {} @@ -53,7 +55,11 @@ export async function makeAPIRequest ({origin, api, username, method, path, head path = path ? joinPaths(apiPath, path) : apiPath // make request - return request({origin, path, method, headers, session}, body) + var n = ++debugRequestCounter + debug(`Request ${n} origin=${origin} path=${path} method=${method} session=${username} body=`, body) + var res = await request({origin, path, method, headers, session}, body) + debug(`Response ${n} statusCode=${res.statusCode} body=`, res.body) + return res } export async function registerHashbase (body) { From 802822a292886dcd2a7c640dfadb55d25b338c89 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 11 May 2018 14:31:59 -0500 Subject: [PATCH 09/10] Implement hashbase registration --- app/background-process/services.js | 28 +++++++--- .../com/settings/dedicated-peers.js | 51 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/background-process/services.js b/app/background-process/services.js index 4b4f8d3347..ee7bffdfd6 100644 --- a/app/background-process/services.js +++ b/app/background-process/services.js @@ -27,16 +27,19 @@ export async function fetchPSADoc (origin, {noCache} = {}) { } // do fetch - var psaDocResponse = await request({ + var n = ++debugRequestCounter + debug(`Request ${n} origin=${origin} path=${path} method=GET (PSA doc fetch)`) + var res = await request({ origin, path: '/.well-known/psa' }) - if (psaDocResponse.success && psaDocResponse.body && typeof psaDocResponse.body == 'object') { - let psaDoc = psaDocResponse.body + 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 psaDocResponse + return res } export async function makeAPIRequest ({origin, api, username, method, path, headers, body}) { @@ -56,18 +59,29 @@ export async function makeAPIRequest ({origin, api, username, method, path, head // make request var n = ++debugRequestCounter - debug(`Request ${n} origin=${origin} path=${path} method=${method} session=${username} body=`, body) + 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} body=`, res.body) + debug(`Response ${n} statusCode=${res.statusCode}`) return res } export async function registerHashbase (body) { - return request({ + // 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) { diff --git a/app/builtin-pages/com/settings/dedicated-peers.js b/app/builtin-pages/com/settings/dedicated-peers.js index 7c517bbdd7..c7305a425a 100644 --- a/app/builtin-pages/com/settings/dedicated-peers.js +++ b/app/builtin-pages/com/settings/dedicated-peers.js @@ -17,6 +17,7 @@ var signinFields = [ {name: 'username', label: 'Username', value: ''}, {name: 'password', label: 'Password', value: '', type: 'password'}, ] +var registeredEmail = false var errors = null // exported api @@ -31,10 +32,18 @@ export default function renderDedicatedPeers (dedicatedPeerAccounts) {

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` @@ -74,8 +83,9 @@ function renderForm () { function renderRegisterForm () { return yo` -
+
+ ${errors ? yo`
${errors.message}
` : ''} ${registerFields.map(renderInput)}
@@ -132,6 +142,7 @@ async function loadAccounts () { function onChangeAddPeerAction (e, value) { formAction = value || e.currentTarget.value errors = null + registeredEmail = false updatePage() // highlight first field @@ -146,6 +157,42 @@ 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() From ce914228dfa6c1c0738c74957c50bb8a5313d63f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 11 May 2018 18:48:23 -0500 Subject: [PATCH 10/10] Implement add/update/remove pin controls --- app/background-process/services.js | 3 +- .../com/library-share-dropdown.js | 143 ++++++++++++++---- app/lib/fg/dedicated-peers.js | 29 +++- .../components/library-share-dropdown.less | 28 ++-- 4 files changed, 151 insertions(+), 52 deletions(-) diff --git a/app/background-process/services.js b/app/background-process/services.js index ee7bffdfd6..ef039716df 100644 --- a/app/background-process/services.js +++ b/app/background-process/services.js @@ -28,7 +28,7 @@ export async function fetchPSADoc (origin, {noCache} = {}) { // do fetch var n = ++debugRequestCounter - debug(`Request ${n} origin=${origin} path=${path} method=GET (PSA doc fetch)`) + debug(`Request ${n} origin=${origin} path=/.well-known/psa method=GET (PSA doc fetch)`) var res = await request({ origin, path: '/.well-known/psa' @@ -50,6 +50,7 @@ export async function makeAPIRequest ({origin, api, username, method, path, head 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) diff --git a/app/builtin-pages/com/library-share-dropdown.js b/app/builtin-pages/com/library-share-dropdown.js index 8f6e360b7c..d41ccebfc9 100644 --- a/app/builtin-pages/com/library-share-dropdown.js +++ b/app/builtin-pages/com/library-share-dropdown.js @@ -1,17 +1,25 @@ /* 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` -