add page and docs
parent
1f50f3ac6d
commit
a40a42af45
|
@ -64,44 +64,48 @@ async function sendUpdatesAsync (id) {
|
||||||
const targets = await TargetUrl.find()
|
const targets = await TargetUrl.find()
|
||||||
if (!book) return
|
if (!book) return
|
||||||
for (const i in targets) {
|
for (const i in targets) {
|
||||||
const item = targets[i]
|
try {
|
||||||
const user = await User.findOne({ id: item.user })
|
const item = targets[i]
|
||||||
const { author: fAuthor, publisher: fPublisher, title: fTitle, isbn: fIsbn, url } = item
|
const user = await User.findOne({ id: item.user })
|
||||||
const { author: bAuthor, publisher: bPublisher, title: bTitle, isbn: bIsbn } = book
|
const { author: fAuthor, publisher: fPublisher, title: fTitle, isbn: fIsbn, url } = item
|
||||||
sails.log('sending ' + book.id + ' info to ' + url)
|
const { author: bAuthor, publisher: bPublisher, title: bTitle, isbn: bIsbn } = book
|
||||||
|
sails.log('sending ' + book.id + ' info to ' + url)
|
||||||
|
|
||||||
if (uriRegex.test(url)) {
|
if (uriRegex.test(url)) {
|
||||||
if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue
|
if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue
|
||||||
if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue
|
if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue
|
||||||
if (fTitle && !((bTitle || '').includes(fTitle))) continue
|
if (fTitle && !((bTitle || '').includes(fTitle))) continue
|
||||||
if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue
|
if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue
|
||||||
|
|
||||||
let content
|
let content
|
||||||
const skipperConfig = sails.config.skipperConfig
|
const skipperConfig = sails.config.skipperConfig
|
||||||
const adapterConfig = { ...skipperConfig, adapter: undefined }
|
const adapterConfig = { ...skipperConfig, adapter: undefined }
|
||||||
const skipperAdapter = skipperConfig.adapter(adapterConfig)
|
const skipperAdapter = skipperConfig.adapter(adapterConfig)
|
||||||
const opdsHelper = await sails.helpers.opds()
|
const opdsHelper = await sails.helpers.opds()
|
||||||
try {
|
try {
|
||||||
if (!book.storage.length) throw new Error('missing book opds file')
|
if (!book.storage.length) throw new Error('missing book opds file')
|
||||||
content = await asyncRead(skipperAdapter, opdsHelper, book.storage)
|
content = await asyncRead(skipperAdapter, opdsHelper, book.storage)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
content = await opdsHelper.book2opds(book)
|
content = await opdsHelper.book2opds(book)
|
||||||
}
|
|
||||||
const timestamp = Date.now()
|
|
||||||
request.post({
|
|
||||||
url: url,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'RoE-aggregator',
|
|
||||||
'X-RoE-Signature': hmacSign(user.signing_secret, timestamp, content),
|
|
||||||
'X-RoE-Request-Timestamp': timestamp
|
|
||||||
},
|
|
||||||
body: content,
|
|
||||||
json: true
|
|
||||||
}, function (err, httpResp, body) {
|
|
||||||
if (err) {
|
|
||||||
sails.log(`error: failed to send book ${id} to ${url}`)
|
|
||||||
}
|
}
|
||||||
})
|
const timestamp = Date.now()
|
||||||
|
request.post({
|
||||||
|
url: url,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'RoE-aggregator',
|
||||||
|
'X-RoE-Signature': hmacSign(user.signing_secret, timestamp, content),
|
||||||
|
'X-RoE-Request-Timestamp': timestamp
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
json: true
|
||||||
|
}, function (err, httpResp, body) {
|
||||||
|
if (err) {
|
||||||
|
sails.log(`error: failed to send book ${id} to ${url}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sails.log(`error: ${e.message}\n${e.stack}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ module.exports = {
|
||||||
|
|
||||||
regenerateSigningSecret: async function (req, res) {
|
regenerateSigningSecret: async function (req, res) {
|
||||||
try {
|
try {
|
||||||
const user = await User.update({ id: req.user.id }, { signing_secret: generateToken({ bytes: 24 }) }).fetch()
|
const user = await User.updateOne({ id: req.user.id }, { signing_secret: await generateToken({ bytes: 24 }) })
|
||||||
return res.json(user)
|
return res.json(user)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (new HttpError(500, e.message)).send(res)
|
return (new HttpError(500, e.message)).send(res)
|
||||||
|
|
|
@ -132,7 +132,7 @@ function PassportHelper () {
|
||||||
user = await User.findOne({ email: userAttrs.email })
|
user = await User.findOne({ email: userAttrs.email })
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await User.create({ userAttrs, signing_secret: generateToken({ bytes: 24 }) }).fetch()
|
user = await User.create({ userAttrs, signing_secret: await generateToken({ bytes: 24 }) }).fetch()
|
||||||
}
|
}
|
||||||
await Passport.create({
|
await Passport.create({
|
||||||
...q,
|
...q,
|
||||||
|
|
|
@ -31,8 +31,8 @@ function generateToken ({ bytes, base }) {
|
||||||
|
|
||||||
function hmacSign (secret, timestamp, body) {
|
function hmacSign (secret, timestamp, body) {
|
||||||
const value = `${APP_VERSION}:${timestamp}:${body}`
|
const value = `${APP_VERSION}:${timestamp}:${body}`
|
||||||
const hmac = crypto.createHmac('sha256', secret).update(value, 'utf-8').digest('hex')
|
const hmac = crypto.createHmac('sha256', secret.toString()).update(value, 'utf-8').digest('hex')
|
||||||
return hmac
|
return `${APP_VERSION}=${hmac}`
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -8,12 +8,19 @@ export default class ConfirmIconButton extends React.Component {
|
||||||
confirmed: false
|
confirmed: false
|
||||||
}
|
}
|
||||||
this.onClick = this.onClick.bind(this)
|
this.onClick = this.onClick.bind(this)
|
||||||
|
this.timer = null
|
||||||
}
|
}
|
||||||
onClick (e) {
|
onClick (e) {
|
||||||
|
e.stopPropagation()
|
||||||
if (this.state.confirmed) {
|
if (this.state.confirmed) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.setState({ confirmed: false })
|
||||||
this.props.onClick(e)
|
this.props.onClick(e)
|
||||||
} else {
|
} else {
|
||||||
this.setState({ confirmed: true })
|
this.setState({ confirmed: true })
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.setState({ confirmed: false })
|
||||||
|
}, 4000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render () {
|
render () {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import IconButton from '../components/IconButton'
|
import IconButton from '../components/IconButton'
|
||||||
|
import ConfirmIconButton from '../containers/ConfirmIconButton'
|
||||||
import UnderlineInput from '../components/UnderlineInput'
|
import UnderlineInput from '../components/UnderlineInput'
|
||||||
import './listitem.scss'
|
import './listitem.scss'
|
||||||
import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
|
import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
|
||||||
|
@ -32,7 +33,7 @@ class UriListItem extends React.Component {
|
||||||
<span className='label'>Filters</span>
|
<span className='label'>Filters</span>
|
||||||
<span className='value'>{['publisher', 'title', 'author', 'isbn'].reduce((a, x) => a + (this.props.item[x] ? 1 : 0), 0) || 'None'}</span>
|
<span className='value'>{['publisher', 'title', 'author', 'isbn'].reduce((a, x) => a + (this.props.item[x] ? 1 : 0), 0) || 'None'}</span>
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
|
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -41,7 +42,7 @@ class UriListItem extends React.Component {
|
||||||
<li className='uri-list-item flex-container flex-vertical editing' onClick={(e) => this.cancelEvent(e, false)}>
|
<li className='uri-list-item flex-container flex-vertical editing' onClick={(e) => this.cancelEvent(e, false)}>
|
||||||
<header className='flex-container' onClick={(e) => this.cancelEvent(e, null)}>
|
<header className='flex-container' onClick={(e) => this.cancelEvent(e, null)}>
|
||||||
<h3 className='flex'>Editing: {this.props.item.url}</h3>
|
<h3 className='flex'>Editing: {this.props.item.url}</h3>
|
||||||
<IconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
|
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
|
||||||
</header>
|
</header>
|
||||||
<div className='settings'>
|
<div className='settings'>
|
||||||
<UnderlineInput
|
<UnderlineInput
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
'use strict'
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react-router-dom'
|
import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react-router-dom'
|
||||||
|
@ -7,7 +5,8 @@ import Progress from './components/Progress'
|
||||||
import UnderlineInput from './components/UnderlineInput'
|
import UnderlineInput from './components/UnderlineInput'
|
||||||
import UriListItem from './containers/UriListItem'
|
import UriListItem from './containers/UriListItem'
|
||||||
import PublisherListItem from './containers/PublisherListItem'
|
import PublisherListItem from './containers/PublisherListItem'
|
||||||
import IconButton from '../components/IconButton'
|
import IconButton from './components/IconButton'
|
||||||
|
import ConfirmIconButton from './containers/ConfirmIconButton'
|
||||||
import reducer from './reducers'
|
import reducer from './reducers'
|
||||||
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher, regenerateSigningSecret } from './actions'
|
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher, regenerateSigningSecret } from './actions'
|
||||||
|
|
||||||
|
@ -208,10 +207,11 @@ class App extends React.Component {
|
||||||
<section className='details'>
|
<section className='details'>
|
||||||
<div className='row'>
|
<div className='row'>
|
||||||
<h3>Signing secret</h3>
|
<h3>Signing secret</h3>
|
||||||
|
<h4>RoE signs the requests we send to you using this unique secret. Confirm that each request comes from RoE by verifying its unique signature.</h4>
|
||||||
<div className='flex-container'>
|
<div className='flex-container'>
|
||||||
<input className='flex' defaultValue={this.state.user.signing_secret} readonly type={this.state.signingSecretShown ? 'text' : 'password'} />
|
<input className='flex' defaultValue={this.state.user.signing_secret} readOnly type={this.state.signingSecretShown ? 'text' : 'password'} />
|
||||||
<IconButton onClick={this.toggleRevealSecret} icon={this.state.signingSecretShown ? 'eye-close' : 'eye'} />
|
<IconButton onClick={this.toggleRevealSecret} icon={this.state.signingSecretShown ? 'eye-close' : 'eye'} />
|
||||||
<IconButton onClick={() => this.dispatch(regenerateSigningSecret())} icon={'refresh'} />
|
<ConfirmIconButton onClick={() => this.dispatch(regenerateSigningSecret())} icon={'refresh'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -46,13 +46,32 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
// overflow: hidden;
|
// overflow: hidden;
|
||||||
}
|
}
|
||||||
.inputs {
|
.inputs,
|
||||||
|
.details {
|
||||||
padding: 20px 14px;
|
padding: 20px 14px;
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
h4 {
|
||||||
|
font-size: .8em;
|
||||||
|
font-weight: normal;
|
||||||
|
color: $black-2;
|
||||||
|
}
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&.working {
|
&.working {
|
||||||
& > .progress {
|
& > .progress {
|
||||||
|
|
|
@ -143,6 +143,8 @@ The server will send a POST request with the following body to the provided URL
|
||||||
```
|
```
|
||||||
HTTP Headers:
|
HTTP Headers:
|
||||||
User-Agent: RoE-aggregator
|
User-Agent: RoE-aggregator
|
||||||
|
X-Roe-Request-Timestamp: number
|
||||||
|
X-Roe-Signature: string
|
||||||
|
|
||||||
HTTP Body:
|
HTTP Body:
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
# River of Ebooks signed requests
|
||||||
|
## Information on how to verify that requests are sent by RoE
|
||||||
|
|
||||||
|
1. Grab your Signing Secret from the bottom of the 'My account' page. In this example, the Signing Secret is `919ac0b6c07b50`. Additionally, extract the raw request body from the request.
|
||||||
|
```js
|
||||||
|
signing_secret = 'ROE_SIGNING_SECRET' // set this as an environment variable
|
||||||
|
>>> 919ac0b6c07b50
|
||||||
|
request_body = request.body()
|
||||||
|
>>> {"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Extract the timestamp header (`1551832182955` in this example). The signature depends on the timestamp to protect against replay attacks. While you're extracting the timestamp, check to make sure that the request occurred recently. In this example, we verify that the timestamp does not differ from local time by more than five minutes.
|
||||||
|
```js
|
||||||
|
timestamp = request.headers['X-RoE-Request-Timestamp']
|
||||||
|
>>> 1551832182955
|
||||||
|
if absolute_value(time.time() - timestamp) > 60 * 5:
|
||||||
|
# The request timestamp is more than five minutes from local time.
|
||||||
|
# It could be a replay attack, so let's ignore it.
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Concatenate the version number (`v0`), the timestamp (`1551832182955`), and the request body (`{"metadata":{"@type":"http...`) together, using a colon (`:`) as a delimiter.
|
||||||
|
```js
|
||||||
|
sig_basestring = 'v0:' + timestamp + ':' + request_body
|
||||||
|
>>> 'v0:1551832182955:{"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ...'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Then hash the resulting string, using the signing secret as a key, and take the hex digest of the hash. In this example, we compute a hex digest of `1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`. The full signature is formed by prefixing the hex digest with `v0=`, to make `v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`.
|
||||||
|
```js
|
||||||
|
my_signature = 'v0=' + hmac.compute_hash_sha256(
|
||||||
|
signing_secret,
|
||||||
|
sig_basestring
|
||||||
|
).hexdigest()
|
||||||
|
>>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Compare the resulting signature to the header on the request.
|
||||||
|
```js
|
||||||
|
signature = request.headers['X-RoE-Signature']
|
||||||
|
>>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23'
|
||||||
|
if (hmac.compare(my_signature, signature)) {
|
||||||
|
deal_with_request(request)
|
||||||
|
}
|
||||||
|
```
|
Loading…
Reference in New Issue