add page, model, controller, and policy for publish keys

pull/36/head
unknown 2019-02-03 14:16:02 -05:00
parent 7a18f7bed8
commit b2166960dd
21 changed files with 10947 additions and 163 deletions

View File

@ -22,7 +22,7 @@ module.exports = {
if (bookExists) { if (bookExists) {
throw new HttpError(400, 'Version already exists') throw new HttpError(400, 'Version already exists')
} else { } else {
result = await Book.create(body) result = await Book.create(body).fetch()
} }
req.file('opds').upload(sails.config.skipperConfig, async function (err, uploaded) { req.file('opds').upload(sails.config.skipperConfig, async function (err, uploaded) {

View File

@ -0,0 +1,44 @@
const HttpError = require('../errors/HttpError')
module.exports = {
create: async function (req, res) {
try {
const url = req.param('url')
if (!url.length) throw new Error('URL cannot be blank')
const created = await PublishKey.create({
user: req.user.id,
url
}).fetch()
return res.json(created)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
list: async function (req, res) {
try {
const keys = await PublishKey.find()
return res.json(keys)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
refresh: async function (req, res) {
try {
const id = req.param('id')
const key = await PublishKey.update({ id, user: req.user.id }, {}).fetch()
return res.json(key)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
delete: async function (req, res) {
try {
const id = req.param('id')
await PublishKey.destroyOne({ id })
return res.status(204).send()
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
}
}

45
api/models/PublishKey.js Normal file
View File

@ -0,0 +1,45 @@
const crypto = require('crypto')
function generateToken({ bytes, base }) {
return new Promise((res, rej) => {
crypto.randomBytes(bytes, (err, buf) => {
if (err) rej(err)
else res(buf.toString(base || 'base64'))
})
})
}
module.exports = {
attributes: {
id: {
type: 'number',
unique: true,
autoIncrement: true
},
user: {
model: 'User',
required: true
},
url: {
type: 'string',
required: true
},
key: {
type: 'string',
required: true
},
secret: {
type: 'string',
required: true
}
},
beforeCreate: async function (key, next) {
key.key = await generateToken({ bytes: 12 })
key.secret = await generateToken({ bytes: 48 })
next()
},
beforeUpdate: async function (key, next) {
key.secret = await generateToken({ bytes: 48 })
next()
}
}

12
api/policies/keyAuth.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = async function (req, res, next) {
const key = req.param('key') || req.header('x-roe-publish-key')
const secret = req.param('secret') || req.header('x-roe-publish-secret')
console.log(key)
console.log(secret)
if (await PublishKey.findOne({ key, secret })) {
return next()
}
res.status(403).json({ error: 'Invalid publishing key.' })
}

View File

@ -9,6 +9,8 @@ const ACTIONS = {
delete_url: 'delete_url', delete_url: 'delete_url',
list_url: 'list_url', list_url: 'list_url',
set_editing: 'set_editing', set_editing: 'set_editing',
add_publisher: 'add_publisher',
delete_publisher: 'delete_publisher',
error: 'error' error: 'error'
} }
@ -29,6 +31,11 @@ export const addUrl = url => ({
data: url data: url
}) })
export const addPublisher = url => ({
type: ACTIONS.add_publisher,
data: url
})
export const setEditing = id => ({ export const setEditing = id => ({
type: ACTIONS.set_editing, type: ACTIONS.set_editing,
data: id data: id
@ -116,3 +123,43 @@ export const setUrl = (value) => async (dispatch, getState) => {
dispatch(setWorking(false)) dispatch(setWorking(false))
} }
} }
export const createNewPublisher = (url) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/keys',
data: {
url
}
})
dispatch(addPublisher(data))
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const removePublisher = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/keys/' + id
})
dispatch({
type: ACTIONS.delete_publisher,
data: id
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}

View File

@ -0,0 +1,31 @@
import React from 'react'
import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput'
import { removePublisher } from '../actions'
import '../../styles/shared/listitem.scss'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
class PublisherListItem extends React.Component {
render () {
return (
<li className='uri-list-item flex-container'>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Website URL</span>
<span className='value'>{this.props.item.url}</span>
</div>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Key</span>
<input className='value' value={this.props.item.key} readOnly={true} />
</div>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Secret</span>
<input className='value' value={this.props.item.secret} readOnly={true} />
</div>
<IconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
</li>
)
}
}
export default PublisherListItem

View File

@ -3,8 +3,8 @@
import React from 'react' import React from 'react'
import IconButton from '../components/IconButton' import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput' import UnderlineInput from '../components/UnderlineInput'
import '../../styles/shared/urilistitem.scss' import '../../styles/shared/listitem.scss'
import { changeUrlField, setUrl, removeUrl, setEditing } from '../actions/targets' import { changeUrlField, setUrl, removeUrl, setEditing } from '../actions'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/ const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/

134
assets/js/index.js Normal file
View File

@ -0,0 +1,134 @@
'use strict'
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react-router-dom'
import Progress from './components/Progress'
import UnderlineInput from './components/UnderlineInput'
import UriListItem from './containers/UriListItem'
import reducer from './reducers'
import { fetchUrls, createNewUrl, setEditing, createNewPublisher } from './actions'
import '../styles/index.scss'
class App extends React.Component {
constructor () {
super()
this.state = {
error: '',
user: {
email: '',
password: ''
},
urls: [],
publishers: [],
newPublisherUrl: '',
editingUrl: null,
working: false
}
this.dispatch = this.dispatch.bind(this)
this.getRegisteredUris = this.getRegisteredUris.bind(this)
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
this.setPublisherUrl = this.setPublisherUrl.bind(this)
}
dispatch (action) {
if (!action) throw new Error('dispatch: missing action')
if (action instanceof Function) {
action(this.dispatch, () => this.state)
} else {
const changes = reducer(this.state, action)
if (!changes || !Object.keys(changes).length) return
this.setState({
...changes
})
}
}
componentDidMount () {
// this.dispatch(fetchUrls())
}
setPublisherUrl (e) {
this.setState({
newPublisherUrl: e.target.value
})
}
getRegisteredUris () {
return this.state.urls.map((item, i) => {
return (<UriListItem
key={i}
dispatch={this.dispatch}
item={item}
editing={this.state.editingUrl === item.id} />)
})
}
getRegisteredPublishers () {
return this.state.publishers.map((item, i) => {
return (<PublisherListItem
key={i}
dispatch={this.dispatch}
item={item} />)
})
}
render () {
return (
<Router>
<div className='root-container flex-container' onClick={() => this.dispatch(setEditing(null))}>
<aside className='nav nav-left'>
<header>
<h1>River of Ebooks</h1>
</header>
<ul>
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
<li><NavLink to='/targets'>Push URIs</NavLink></li>
</ul>
</aside>
<section className={'content flex' + (this.state.working ? ' working' : '')}>
<Progress bound />
{this.state.error && <div className='error-box'>{this.state.error}</div>}
<Switch>
<Route path='/keys' exact children={props => (
<div>
<header className='flex-container'>
<div className='flex'>
<h1>Publishing keys</h1>
<h2>If you own a publishing site, generate a publishing key for it here.</h2>
</div>
</header>
<div className='creator flex-container'>
<UnderlineInput
className='flex'
placeholder='Site URL'
value={this.state.newPublisherUrl}
onChange={this.setPublisherUrl} />
<button className='btn' onClick={() => this.dispatch(createNewPublisher(this.state.newPublisherUrl))}>Create keys</button>
</div>
<ul className='list'>
{this.getRegisteredPublishers()}
</ul>
</div>
)} />
<Route path='/targets' exact children={props => (
<div>
<header className='flex-container'>
<div className='flex'>
<h1>Push URIs</h1>
<h2>Newly published books will be sent to these addresses.</h2>
</div>
<button className='btn' onClick={() => this.dispatch(createNewUrl())}>New address</button>
</header>
<ul className='list'>
{this.getRegisteredUris()}
</ul>
</div>
)} />
<Route path='/' render={() => <Redirect to='/keys' />} />
</Switch>
</section>
</div>
</Router>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
import Actions from '../actions/targets' import Actions from '../actions'
const reducer = (state = {}, action) => { const reducer = (state = {}, action) => {
const { type, data } = action const { type, data } = action
@ -34,6 +34,16 @@ const reducer = (state = {}, action) => {
return { return {
editingUrl: data editingUrl: data
} }
case Actions.add_publisher:
return {
publishers: state.publishers.concat(data),
error: ''
}
case Actions.delete_publisher:
return {
publishers: state.publishers.filter(x => x.id !== data),
error: ''
}
case Actions.error: case Actions.error:
return { return {
error: data.message error: data.message

View File

@ -1,79 +0,0 @@
'use strict'
import React from 'react'
import ReactDOM from 'react-dom'
import Progress from './components/Progress'
import UriListItem from './containers/UriListItem'
import reducer from './reducers/targets'
import { fetchUrls, createNewUrl, setEditing } from './actions/targets'
import '../styles/targets.scss'
class App extends React.Component {
constructor () {
super()
this.state = {
error: '',
user: {
email: '',
password: ''
},
urls: [],
editingUrl: null,
working: false
}
this.dispatch = this.dispatch.bind(this)
this.getRegisteredUris = this.getRegisteredUris.bind(this)
}
dispatch (action) {
if (!action) throw new Error('dispatch: missing action')
if (action instanceof Function) {
action(this.dispatch, () => this.state)
} else {
const changes = reducer(this.state, action)
if (!changes || !Object.keys(changes).length) return
this.setState({
...changes
})
}
}
componentDidMount () {
this.dispatch(fetchUrls())
}
getRegisteredUris () {
return this.state.urls.map((item, i) => {
return (<UriListItem
key={i}
dispatch={this.dispatch}
item={item}
editing={this.state.editingUrl === item.id} />)
})
}
render () {
return (
<div className='root-container flex-container' onClick={() => this.dispatch(setEditing(null))}>
<aside className='nav nav-left'>
<header>
<h1>RoE</h1>
</header>
</aside>
<section className={'content flex' + (this.state.working ? ' working' : '')}>
<Progress bound />
{this.state.error && <div className='error-box'>{this.state.error}</div>}
<header className='flex-container'>
<div className='flex'>
<h1>Push URIs</h1>
<h2>Newly published books will be sent to these addresses.</h2>
</div>
<button className='btn' onClick={() => this.dispatch(createNewUrl())}>New address</button>
</header>
<ul className='list'>
{this.getRegisteredUris()}
</ul>
</section>
</div>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))

56
assets/styles/index.scss Normal file
View File

@ -0,0 +1,56 @@
@import 'lib/default';
@import 'shared/twopanels';
.content {
padding: 14px 0 42px 0;
position: relative;
.error-box {
height: 30px;
line-height: 30px;
background: $red;
color: white;
padding: 0 14px;
margin: -14px 0 8px 0;
}
& > div {
& > header {
padding: 0 14px;
h1 {
text-shadow: 1px 1px 2px $black-3;
}
h2 {
margin: 0;
padding: 0;
font-weight: normal;
font-size: 16px;
margin-top: 4px;
color: $text-dark-2;
text-shadow: 1px 1px 2px $black-4;
}
}
.creator {
padding: 0 14px;
line-height: 60px;
max-width: 500px;
.btn {
margin: 12px 0 12px 12px;
}
}
}
.list {
margin: 20px 14px;
padding: 0;
list-style: none;
// overflow: hidden;
}
&.working {
& > .progress {
top: 0;
height: 4px;
}
}
}

View File

@ -14,6 +14,12 @@ $black-3: rgba(0,0,0,.38);
$black-4: rgba(0,0,0,.12); $black-4: rgba(0,0,0,.12);
$black-5: rgba(0,0,0,.07); $black-5: rgba(0,0,0,.07);
$white-1: white;
$white-2: rgba(255,255,255,.75);
$white-3: rgba(255,255,255,.35);
$white-4: rgba(255,255,255,.10);
$white-5: rgba(255,255,255,.03);
$auth-width: 450px; $auth-width: 450px;
$background-1: #f2f2f2; $background-1: #f2f2f2;
@ -21,8 +27,8 @@ $background-2: white;
$text-dark-1: $black-1; $text-dark-1: $black-1;
$text-dark-2: $black-2; $text-dark-2: $black-2;
$text-light-1: white; $text-light-1: $white-1;
$text-light-2: rgba(255,255,255,.75); $text-light-2: $white-2;
$accent-1: #731212; $accent-1: #731212;
$accent-2: #9a834d; $accent-2: #9a834d;

View File

@ -4,4 +4,41 @@
background: $accent-1; background: $accent-1;
color: $text-light-1; color: $text-light-1;
box-shadow: $shadow-1; box-shadow: $shadow-1;
header {
height: 50px;
line-height: 50px;
padding: 0 14px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
height: 50px;
line-height: 50px;
border-bottom: 1px solid $white-4;
&:hover a {
background: $white-5;
}
&:last-of-type {
border-bottom: none
}
a {
display: inline-block;
height: 100%;
width: 100%;
padding: 0 12px;
text-decoration: none !important;
color: $white-2;
&.active {
background: $white-4;
}
}
}
}
} }

View File

@ -1,51 +0,0 @@
@import 'lib/default';
@import 'shared/twopanels';
.nav {
header {
height: 50px;
line-height: 50px;
padding: 0 14px;
}
}
.content {
padding: 14px 0 42px 0;
position: relative;
.error-box {
height: 30px;
line-height: 30px;
background: $red;
color: white;
padding: 0 14px;
margin: -14px 0 8px 0;
}
& > header {
padding: 0 14px;
h1 {
text-shadow: 1px 1px 2px $black-3;
}
h2 {
margin: 0;
padding: 0;
font-weight: normal;
font-size: 16px;
margin-top: 4px;
color: $text-dark-2;
text-shadow: 1px 1px 2px $black-4;
}
}
.list {
margin: 20px 14px;
padding: 0;
list-style: none;
// overflow: hidden;
}
&.working {
& > .progress {
top: 0;
height: 4px;
}
}
}

View File

@ -4,7 +4,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>RoE - Push Targets</title> <title>River of Ebooks</title>
<% for (item of htmlWebpackPlugin.options.links) { <% for (item of htmlWebpackPlugin.options.links) {
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %> if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><% <link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%

View File

@ -33,5 +33,14 @@ module.exports.policies = {
TargetController: { TargetController: {
'*': [ 'sessionAuth' ] '*': [ 'sessionAuth' ]
},
PublishKeyController: {
'*': [ 'sessionAuth' ]
},
BooksController: {
'*': true,
publish: [ 'keyAuth' ]
} }
} }

View File

@ -69,9 +69,14 @@ module.exports.routes = {
'GET /api/me': 'UserController.me', 'GET /api/me': 'UserController.me',
'POST /api/targets': 'TargetController.create', 'POST /api/targets': 'TargetController.create',
'GET /api/targets': 'TargetController.list',
'PATCH /api/targets/:id': 'TargetController.edit', 'PATCH /api/targets/:id': 'TargetController.edit',
'DELETE /api/targets/:id': 'TargetController.delete', 'DELETE /api/targets/:id': 'TargetController.delete',
'GET /api/targets': 'TargetController.list'
'POST /api/keys': 'PublishKeyController.create',
'GET /api/keys': 'PublishKeyController.list',
'PATCH /api/keys/:id': 'PublishKeyController.refresh',
'DELETE /api/keys/:id': 'PublishKeyController.delete'
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗ // ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗ // ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗

View File

@ -4,6 +4,25 @@
"version": "0.0.0", "version": "0.0.0",
"description": "a Sails application", "description": "a Sails application",
"keywords": [], "keywords": [],
"scripts": {
"start": "npm-run-all --parallel open:client lift",
"start:debug": "npm-run-all --parallel open:client debug",
"start:prod": "npm-run-all --parallel build:prod lift",
"open:client": "webpack-dev-server --mode development",
"build": "npm run build:prod",
"build:dev": "webpack --mode development",
"build:prod": "webpack --mode production",
"clean": "rimraf .tmp && mkdirp .tmp/public",
"lift": "sails lift",
"forever": "sudo ./node_modules/.bin/pm2 start ecosystem.config.js --env production",
"stop": "sudo ./node_modules/.bin/pm2 delete roe-base",
"test": "npm run lint && npm run custom-tests && echo 'Done.'",
"lint": "standard && echo '✔ Your .js files look good.'",
"debug": "node --inspect app.js",
"custom-tests": "echo 'Nothing yet'",
"db:migrate": "knex migrate:latest",
"db:rollback": "knex migrate:rollback"
},
"dependencies": { "dependencies": {
"@sailshq/connect-redis": "^3.2.1", "@sailshq/connect-redis": "^3.2.1",
"@sailshq/lodash": "^3.10.3", "@sailshq/lodash": "^3.10.3",
@ -42,6 +61,7 @@
"mocha": "^5.2.0", "mocha": "^5.2.0",
"node-sass": "^4.9.4", "node-sass": "^4.9.4",
"npm-run-all": "^4.1.3", "npm-run-all": "^4.1.3",
"react-router-dom": "^4.3.1",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"standard": "^12.0.1", "standard": "^12.0.1",
@ -50,25 +70,6 @@
"webpack-cli": "^3.1.2", "webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.10" "webpack-dev-server": "^3.1.10"
}, },
"scripts": {
"start": "npm-run-all --parallel open:client lift",
"start:debug": "npm-run-all --parallel open:client debug",
"start:prod": "npm-run-all --parallel build:prod lift",
"open:client": "webpack-dev-server --mode development",
"build": "npm run build:prod",
"build:dev": "webpack --mode development",
"build:prod": "webpack --mode production",
"clean": "rimraf .tmp && mkdirp .tmp/public",
"lift": "sails lift",
"forever": "sudo ./node_modules/.bin/pm2 start ecosystem.config.js --env production",
"stop": "sudo ./node_modules/.bin/pm2 delete roe-base",
"test": "npm run lint && npm run custom-tests && echo 'Done.'",
"lint": "standard && echo '✔ Your .js files look good.'",
"debug": "node --inspect app.js",
"custom-tests": "echo 'Nothing yet'",
"db:migrate": "knex migrate:latest",
"db:rollback": "knex migrate:rollback"
},
"main": "app.js", "main": "app.js",
"repository": { "repository": {
"type": "git", "type": "git",
@ -86,6 +87,7 @@
"Book", "Book",
"Passport", "Passport",
"TargetUrl", "TargetUrl",
"PublishKey",
"_" "_"
], ],
"env": [ "env": [

10471
shrinkwrap.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ module.exports = (env, argv) => {
mode: mode || 'development', mode: mode || 'development',
entry: { entry: {
login: './assets/js/login.js', login: './assets/js/login.js',
targets: './assets/js/targets.js' index: './assets/js/index.js'
}, },
output: { output: {
path: path.join(__dirname, '/.tmp/public'), path: path.join(__dirname, '/.tmp/public'),
@ -41,10 +41,10 @@ module.exports = (env, argv) => {
chunks: ['login'] chunks: ['login']
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: 'assets/templates/targets.html', template: 'assets/templates/index.html',
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'targets.css' }] : [], links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'index.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/targets.html'), filename: path.join(__dirname, '/.tmp/public/index.html'),
chunks: ['targets'] chunks: ['index']
}), }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: '[name].css' filename: '[name].css'
@ -52,6 +52,11 @@ module.exports = (env, argv) => {
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(mode) 'process.env.NODE_ENV': JSON.stringify(mode)
}) })
] ],
devServer: {
historyApiFallback: true,
disableHostCheck: true,
port: 8080
}
} }
} }