Merge pull request #41 from EbookFoundation/feature/sitewide-keys

Feature/sitewide keys
pull/42/head
Theodore Kluge 2019-02-26 19:00:40 -05:00 committed by GitHub
commit dc7b351bd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 2601 additions and 185 deletions

View File

@ -0,0 +1,75 @@
const HttpError = require('../errors/HttpError')
module.exports = {
show: async function (req, res) {
res.view('pages/admin', {
email: req.user.email
})
},
listUsers: async function (req, res) {
try {
const users = await User.find({})
return res.json(users)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
listPublishers: async function (req, res) {
try {
const publishers = await PublishKey.find({
select: ['id', 'user', 'appid', 'url', 'name', 'whitelisted', 'verified', 'verification_key', 'created_at', 'updated_at']
}).populate('user')
return res.json(publishers)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
editUser: async function (req, res) {
try {
const id = req.param('id')
const patchData = req.param('patch')
const updated = await User.updateOne({ id }).set({
...patchData
})
for (const key in updated) {
if (patchData[key] === undefined && key !== 'id') delete updated[key]
}
return res.json(updated)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
editPublisher: async function (req, res) {
try {
const id = req.param('id')
const patchData = req.param('patch')
const updated = await PublishKey.updateOne({ id }).set({
...patchData
})
for (const key in updated) {
if (patchData[key] === undefined && key !== 'id') delete updated[key]
}
return res.json(updated)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
deleteUser: async function (req, res) {
try {
const id = req.param('id')
await User.destroyOne({ id })
return res.status(204).send()
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
deletePublisher: 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)
}
}
}

View File

@ -1,13 +1,19 @@
const HttpError = require('../errors/HttpError')
const request = require('request')
const url = require('url')
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?/i
module.exports = {
create: async function (req, res) {
try {
const name = req.param('name')
const url = req.param('url')
if (!name.length) throw new Error('Name cannot be blank')
if (!url.length) throw new Error('URL cannot be blank')
if (!uriRegex.test(url)) throw new Error('Invalid URL')
const created = await PublishKey.create({
user: req.user.id,
name,
url
}).fetch()
return res.json(created)
@ -17,7 +23,7 @@ module.exports = {
},
list: async function (req, res) {
try {
const keys = await PublishKey.find()
const keys = await PublishKey.find({ user: req.user.id })
return res.json(keys)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
@ -40,5 +46,44 @@ module.exports = {
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
verify: async function (req, res) {
try {
const id = req.param('id')
const key = await PublishKey.findOne({ id })
if (!key) throw new HttpError(404, 'Cannot find that key')
if (key.verified) throw new HttpError(400, 'That key\'s domain has already been verified')
const verification = key.verification_key
const _url = url.resolve(key.url, `${verification}.html`)
const { httpResp, body } = await requestAsync({
url: _url,
headers: { 'User-Agent': 'RoE-aggregator' }
})
if (httpResp.statusCode !== 200 || body !== `${verification}.html`) throw new HttpError(404, `Could not find ${_url}`)
const updated = await PublishKey.updateOne({ id }).set({
verified: true,
verification_key: ''
})
return res.json(updated)
} catch (e) {
if (e instanceof HttpError) return e.send(res)
else return (new HttpError(500, e.message)).send(res)
}
}
}
function requestAsync (opts) {
return new Promise((resolve, reject) => {
request.get(opts, function (err, httpResp, body) {
if (err) {
reject(err)
} else {
resolve({ httpResp, body })
}
})
})
}

View File

@ -1,10 +1,10 @@
const crypto = require('crypto')
function generateToken({ bytes, base }) {
return new Promise((res, rej) => {
function generateToken ({ bytes, base }) {
return new Promise((resolve, reject) => {
crypto.randomBytes(bytes, (err, buf) => {
if (err) rej(err)
else res(buf.toString(base || 'base64'))
if (err) reject(err)
else resolve(buf.toString(base || 'base64'))
})
})
}
@ -20,22 +20,25 @@ module.exports = {
model: 'User',
required: true
},
url: {
name: {
type: 'string',
required: true
},
key: {
type: 'string',
required: true
url: 'string',
whitelisted: 'boolean',
verified: 'boolean',
verification_key: 'string',
appid: {
type: 'string'
},
secret: {
type: 'string',
required: true
type: 'string'
}
},
beforeCreate: async function (key, next) {
key.key = await generateToken({ bytes: 12 })
key.appid = await generateToken({ bytes: 12 })
key.secret = await generateToken({ bytes: 48 })
key.verification_key = await generateToken({ bytes: 24, base: 'hex' })
next()
},
beforeUpdate: async function (key, next) {

View File

@ -18,7 +18,8 @@ module.exports = {
},
email: {
type: 'string'
}
},
admin: 'boolean'
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗

View File

@ -0,0 +1,5 @@
module.exports = async function (req, res, next) {
if (process.env.NODE_ENV === 'development') return next()
if (req.user && (req.user.id === 1 || req.user.admin)) next()
else res.status(403).json({ error: 'You are not permitted to perform this action.' })
}

View File

@ -1,11 +1,11 @@
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)
const key = req.param('key') || req.headers['roe-key']
const secret = req.param('secret') || req.headers['roe-secret']
if (await PublishKey.findOne({ key, secret })) {
return next()
const pk = await PublishKey.findOne({ appid: key, secret })
if (pk) {
if (pk.whitelisted) return next()
else res.status(403).json({ error: 'Your key has not been whitelisted yet. Please contact the site operator.' })
}
res.status(403).json({ error: 'Invalid publishing key.' })

View File

@ -6,9 +6,8 @@
* @docs :: http://sailsjs.org/#!documentation/policies
*/
module.exports = function (req, res, next) {
if (req.session.authenticated) {
return next()
}
if (process.env.NODE_ENV === 'development') return next()
if (req.session.authenticated) return next()
// res.status(403).json({ error: 'You are not permitted to perform this action.' })
res.redirect('/login')
}

101
assets/js/actions/admin.js Normal file
View File

@ -0,0 +1,101 @@
'use strict'
import Ajax from '../lib/Ajax'
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
const ACTIONS = {
set_working: 'set_working',
error: 'error',
set_admin_data: 'set_admin_data',
a_update_user: 'a_update_user',
a_update_publisher: 'a_update_publisher'
}
export default ACTIONS
export const setWorking = working => ({
type: ACTIONS.set_working,
data: working
})
export const fetchAdminData = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: user } = await Ajax.get({
url: getPath('/api/me')
})
const { data: users } = await Ajax.get({
url: getPath('/admin/api/users')
})
const { data: publishers } = await Ajax.get({
url: getPath('/admin/api/publishers')
})
dispatch({
type: ACTIONS.set_admin_data,
data: {
user,
users,
publishers
}
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const patchUser = ({ id, ...data }) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: user } = await Ajax.patch({
url: getPath('/admin/api/users/' + id),
data: {
patch: data
},
noProcess: true
})
dispatch({
type: ACTIONS.a_update_user,
data: user
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const patchPublisher = ({ id, ...data }) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: publisher } = await Ajax.patch({
url: getPath('/admin/api/publishers/' + id),
data: {
patch: data
},
noProcess: true
})
dispatch({
type: ACTIONS.a_update_publisher,
data: publisher
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}

View File

@ -1,6 +1,9 @@
'use strict'
import Ajax from '../lib/Ajax'
const FileSaver = require('file-saver')
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
const ACTIONS = {
set_working: 'set_working',
@ -8,11 +11,14 @@ const ACTIONS = {
edit_url: 'edit_url',
delete_url: 'delete_url',
list_url: 'list_url',
set_editing: 'set_editing',
set_editing_uri: 'set_editing_uri',
set_editing_publisher: 'set_editing_publisher',
error: 'error',
set_user: 'set_user',
add_publisher: 'add_publisher',
delete_publisher: 'delete_publisher'
delete_publisher: 'delete_publisher',
set_publishers: 'set_publishers',
update_publisher: 'update_publisher'
}
export default ACTIONS
@ -32,6 +38,11 @@ export const setUser = user => ({
data: user
})
export const setPublishers = user => ({
type: ACTIONS.set_publishers,
data: user
})
export const addUrl = url => ({
type: ACTIONS.add_url,
data: url
@ -42,8 +53,23 @@ export const addPublisher = url => ({
data: url
})
export const setEditing = id => ({
type: ACTIONS.set_editing,
export const updatePublisher = data => ({
type: ACTIONS.update_publisher,
data: data
})
export const setEditing = () => (dispatch) => {
dispatch(setEditingUri(null))
dispatch(setEditingPublisher(null))
}
export const setEditingUri = id => ({
type: ACTIONS.set_editing_uri,
data: id
})
export const setEditingPublisher = id => ({
type: ACTIONS.set_editing_publisher,
data: id
})
@ -60,7 +86,7 @@ export const removeUrl = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/targets/' + id
url: getPath('/api/targets/' + id)
})
dispatch({
type: ACTIONS.delete_url,
@ -80,13 +106,18 @@ export const fetchData = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: user } = await Ajax.get({
url: '/api/me'
url: getPath('/api/me')
})
dispatch(setUser(user))
const { data: urls } = await Ajax.get({
url: '/api/targets'
url: getPath('/api/targets')
})
dispatch(setUrls(urls))
const { data: publishers } = await Ajax.get({
url: getPath('/api/keys')
})
dispatch(setPublishers(publishers))
} catch (e) {
dispatch({
type: ACTIONS.error,
@ -101,7 +132,7 @@ export const createNewUrl = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/targets'
url: getPath('/api/targets')
})
dispatch(addUrl(data))
} catch (e) {
@ -118,7 +149,7 @@ export const setUrl = (value) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.patch({
url: '/api/targets/' + value.id,
url: getPath('/api/targets/' + value.id),
data: {
...value,
id: undefined
@ -140,7 +171,7 @@ export const editUser = (user) => async (dispatch, getState) => {
try {
// if (!user.currentPassword) throw new Error('Please enter your current password.')
await Ajax.patch({
url: '/api/me',
url: getPath('/api/me'),
data: {
id: user.id,
email: user.email,
@ -151,7 +182,7 @@ export const editUser = (user) => async (dispatch, getState) => {
dispatch({
type: ACTIONS.error,
data: null
})
})
} catch (e) {
dispatch({
type: ACTIONS.error,
@ -162,12 +193,13 @@ export const editUser = (user) => async (dispatch, getState) => {
}
}
export const createNewPublisher = (url) => async (dispatch, getState) => {
export const createNewPublisher = ({ name, url }) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/keys',
url: getPath('/api/keys'),
data: {
name,
url
}
})
@ -186,7 +218,7 @@ export const removePublisher = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/keys/' + id
url: getPath('/api/keys/' + id)
})
dispatch({
type: ACTIONS.delete_publisher,
@ -201,3 +233,26 @@ export const removePublisher = id => async (dispatch, getState) => {
dispatch(setWorking(false))
}
}
export const verifyDomain = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: getPath(`/api/keys/${id}/verify`)
})
dispatch(updatePublisher(data))
dispatch(setEditingPublisher(null))
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const saveFile = data => (dispatch) => {
var blob = new Blob([data], { type: 'text/plain;charset=utf-8' })
FileSaver.saveAs(blob, data)
}

View File

@ -2,6 +2,8 @@
import Ajax from '../lib/Ajax'
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
const ACTIONS = {
set_working: 'set_working',
set_user: 'set_user',
@ -47,7 +49,7 @@ export const clearError = () => ({
export const setLoggedIn = (data) => (dispatch, getState) => {
window.localStorage.setItem('roe-token', JSON.stringify(data))
window.location.href = '/targets'
window.location.href = '/keys'
}
export const checkEmail = email => async (dispatch, getState) => {
@ -56,7 +58,7 @@ export const checkEmail = email => async (dispatch, getState) => {
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
try {
await Ajax.post({
url: '/auth/email_exists',
url: getPath('/auth/email_exists'),
data: {
email
}
@ -83,7 +85,7 @@ export const checkPassword = (email, password) => async (dispatch, getState) =>
// do email + password check
try {
const res = await Ajax.post({
url: '/auth/local',
url: getPath('/auth/local'),
data: {
identifier: email,
password
@ -106,13 +108,13 @@ export const signup = (email, password) => async (dispatch, getState) => {
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
try {
await Ajax.post({
url: '/auth/email_available',
url: getPath('/auth/email_available'),
data: {
email
}
})
await Ajax.post({
url: '/register',
url: getPath('/register'),
data: {
email,
password

170
assets/js/admin.js Normal file
View File

@ -0,0 +1,170 @@
'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 appReducer from './reducers'
import adminReducer from './reducers/admin'
import { fetchAdminData, patchUser, patchPublisher } from './actions/admin'
import Util from './lib/Util'
import Icon from './components/Icon'
import '../styles/admin.scss'
import './containers/listitem.scss'
const reducer = Util.combineReducers(appReducer, adminReducer)
class App extends React.Component {
constructor () {
super()
this.state = {
error: '',
user: {
id: '',
email: '',
password: '',
currentPassword: ''
},
users: [],
publishers: [],
working: false
}
this.dispatch = this.dispatch.bind(this)
this.getRegisteredUsers = this.getRegisteredUsers.bind(this)
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
/*
this.state.user = {
...this.state.user,
'created_at': 1551151466802,
'updated_at': 1551151520134,
'id': 1,
'email': 'admin@tkluge.net',
'admin': true
}
*/
}
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(fetchAdminData())
}
getRegisteredUsers () {
return this.state.users.map(user => {
return (
<li className='uri-list-item flex-container' key={`is-admin-${user.id}`}>
<span className='flex'>{user.email}</span>
<span className='flex'>
<label for={`is-admin-${user.id}`} className='cb-label'>Admin?</label>
<input className='checkbox' type='checkbox' checked={user.admin} onChange={() => this.dispatch(patchUser({ id: user.id, admin: !user.admin }))} id={`is-admin-${user.id}`} />
<label htmlFor={`is-admin-${user.id}`} />
</span>
<div className='stack flex flex-container flex-vertical'>
<span className='flex'><span className='key'>Created at:</span><span className='value'>{new Date(user.created_at).toLocaleString()}</span></span>
<span className='flex'><span className='key'>Updated at:</span><span className='value'>{new Date(user.updated_at).toLocaleString()}</span></span>
</div>
</li>
)
})
}
getRegisteredPublishers () {
return this.state.publishers.map(pub => {
return (
<li className='uri-list-item flex-container flex-vertical' key={`is-whitelisted-${pub.id}`}>
<header><h3>{pub.name}</h3></header>
<div className='flex flex-container'>
<div className='flex flex-container flex-vertical key-value'>
<span className='flex'><span className='key'>Owner:</span><span className='value'>{pub.user.email}</span></span>
<span className='flex'><span className='key'>App ID:</span><span className='value'>{pub.appid}</span></span>
<span className='flex contains-icon'>
<span className='key'>App domain:</span>
<span className='value'>{pub.url}</span>
<Icon icon={pub.verified ? 'shield-check' : 'alert-circle'} className={pub.verified ? 'verified' : 'unverified'} />
</span>
</div>
<span className='flex'>
<label htmlFor={`is-whitelisted-${pub.id}`} className='cb-label'>Whitelisted?</label>
<input className='checkbox' type='checkbox' checked={pub.whitelisted} onChange={() => this.dispatch(patchPublisher({ id: pub.id, whitelisted: !pub.whitelisted }))} id={`is-whitelisted-${pub.id}`} />
<label htmlFor={`is-whitelisted-${pub.id}`} />
</span>
<div className='stack flex flex-container flex-vertical'>
<span className='flex'><span className='key'>Created at:</span><span className='value'>{new Date(pub.created_at).toLocaleString()}</span></span>
<span className='flex'><span className='key'>Updated at:</span><span className='value'>{new Date(pub.updated_at).toLocaleString()}</span></span>
</div>
</div>
</li>
)
})
}
render () {
return (
<Router basename='/admin'>
<div className='root-container flex-container admin-container'>
<aside className='nav nav-left'>
<header>
<h1>RoE Admin</h1>
<h2 className='flex-container'>
<span className='flex'>{this.state.user.email}</span>
<a href='/logout'>Log out</a>
</h2>
</header>
<ul>
<li><NavLink to='/users'>Users</NavLink></li>
<li><NavLink to='/publishers'>Publishers</NavLink></li>
<li><a href='/keys'>Exit admin</a></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='/users' exact children={props => (
<div>
<header className='flex-container'>
<div className='flex'>
<h1>Site users</h1>
<h2>Registered users on RoE</h2>
</div>
</header>
<ul className='list'>
{this.getRegisteredUsers()}
</ul>
</div>
)} />
<Route path='/publishers' exact children={props => (
<div>
<header className='flex-container'>
<div className='flex'>
<h1>Publishers</h1>
<h2>Whitelist sites who can publish books</h2>
</div>
</header>
<ul className='list'>
{this.getRegisteredPublishers()}
</ul>
</div>
)} />
<Route path='/' render={() => <Redirect to='/users' />} />
</Switch>
</section>
</div>
</Router>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))

View File

@ -0,0 +1,34 @@
import React from 'react'
import './icon.scss'
function getSVG (icon) {
switch (icon) {
case 'delete': return '<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" />'
case 'add': return '<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />'
case 'pound': return '<path d="M5.41,21L6.12,17H2.12L2.47,15H6.47L7.53,9H3.53L3.88,7H7.88L8.59,3H10.59L9.88,7H15.88L16.59,3H18.59L17.88,7H21.88L21.53,9H17.53L16.47,15H20.47L20.12,17H16.12L15.41,21H13.41L14.12,17H8.12L7.41,21H5.41M9.53,9L8.47,15H14.47L15.53,9H9.53Z" />'
case 'filter-variant': return '<path d="M6,13H18V11H6M3,6V8H21V6M10,18H14V16H10V18Z" />'
case 'close': return '<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />'
case 'check': return '<path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />'
case 'chevron-down': return '<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />'
case 'account-network': return '<path d="M13,17V19H14A1,1 0 0,1 15,20H22V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H5V15.5C5,13.57 8.13,12 12,12C15.87,12 19,13.57 19,15.5V17H13M12,3A3.5,3.5 0 0,1 15.5,6.5A3.5,3.5 0 0,1 12,10A3.5,3.5 0 0,1 8.5,6.5A3.5,3.5 0 0,1 12,3Z" />'
case 'logout': return '<path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M19,3A2,2 0 0,1 21,5V9.67L19,7.67V5H5V19H19V16.33L21,14.33V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5C3,3.89 3.89,3 5,3H19Z" />'
case 'square-edit-outline': return '<path d="M5,3C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19H5V5H12V3H5M17.78,4C17.61,4 17.43,4.07 17.3,4.2L16.08,5.41L18.58,7.91L19.8,6.7C20.06,6.44 20.06,6 19.8,5.75L18.25,4.2C18.12,4.07 17.95,4 17.78,4M15.37,6.12L8,13.5V16H10.5L17.87,8.62L15.37,6.12Z" />'
case 'eye': return '<path d="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z" />'
case 'eye-close': return ' <path d="M11.83,9L15,12.16C15,12.11 15,12.05 15,12A3,3 0 0,0 12,9C11.94,9 11.89,9 11.83,9M7.53,9.8L9.08,11.35C9.03,11.56 9,11.77 9,12A3,3 0 0,0 12,15C12.22,15 12.44,14.97 12.65,14.92L14.2,16.47C13.53,16.8 12.79,17 12,17A5,5 0 0,1 7,12C7,11.21 7.2,10.47 7.53,9.8M2,4.27L4.28,6.55L4.73,7C3.08,8.3 1.78,10 1,12C2.73,16.39 7,19.5 12,19.5C13.55,19.5 15.03,19.2 16.38,18.66L16.81,19.08L19.73,22L21,20.73L3.27,3M12,7A5,5 0 0,1 17,12C17,12.64 16.87,13.26 16.64,13.82L19.57,16.75C21.07,15.5 22.27,13.86 23,12C21.27,7.61 17,4.5 12,4.5C10.6,4.5 9.26,4.75 8,5.2L10.17,7.35C10.74,7.13 11.35,7 12,7Z" />'
case 'shield-check': return '<path d="M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z" />'
case 'alert-circle': return '<path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />'
default: return icon || 'missing icon prop'
}
}
function bsvg (icon) {
return `<svg viewBox="0 0 24 24">${getSVG(icon)}</svg>`
}
const Icon = props => {
return (
<span className={'icon' + (props.className ? ' ' + props.className : '')} dangerouslySetInnerHTML={{ __html: bsvg(props.icon) }} />
)
}
export default Icon

View File

@ -1,18 +1,14 @@
'use strict'
import React from 'react'
import '../../styles/shared/iconbutton.scss'
function getSVG (icon) {
switch (icon) {
case 'delete': return '<svg viewBox="0 0 24 24"><path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" /></svg>'
default: return icon || 'missing icon prop'
}
}
import Icon from './Icon'
import './IconButton.scss'
const IconButton = props => {
return (
<button className='button icon' onClick={props.onClick} dangerouslySetInnerHTML={{ __html: getSVG(props.icon) }} />
<button className={'button icon' + (props.className ? ' ' + props.className : '')} onClick={props.onClick}>
<Icon icon={props.icon} />
</button>
)
}

View File

@ -2,7 +2,7 @@
import React from 'react'
import '../../styles/shared/underlineinput.scss'
import './underlineinput.scss'
const UnderlineInput = props => (
<div className={'underlined-input ' + (props.className ? props.className : '')}>

View File

@ -0,0 +1,20 @@
@import '../../styles/lib/vars';
.icon {
display: inline-block;
height: 40px;
line-height: 40px;
width: 40px;
padding: 5px;
background: transparent;
border-radius: 50%;
svg {
height: 30px;
width: 30px;
path {
fill: $white-2;
}
}
}

View File

@ -1,10 +1,11 @@
@import '../lib/vars';
@import '../../styles/lib/vars';
.button.icon {
height: 40px;
line-height: 40px;
width: 40px;
padding: 5px;
// padding: 5px;
text-align: center;
background: transparent;
border: none;
outline: none;
@ -17,7 +18,14 @@
background: $black-4;
box-shadow: $shadow-1;
}
path {
fill: $black-2;
.icon {
padding: 0;
height: 30px;
line-height: 30px;
width: 30px;
path {
fill: $black-2;
}
}
}

View File

@ -1,4 +1,4 @@
@import '../lib/vars';
@import '../../styles/lib/vars';
.underlined-input,
.underlined-input-readonly {
@ -93,6 +93,12 @@
& + .underlined-input,
& + .underlined-input-readonly {
margin-top: 8px
margin-top: 8px;
}
& + .underlined-input.stack-h,
& + .underlined-input-readonly.stack-h {
margin-left: 14px;
margin-top: 0;
}
}

View File

@ -1,7 +1,7 @@
'use strict'
import React from 'react'
import '../../styles/shared/carousel.scss'
import './carousel.scss'
class Carousel extends React.Component {
constructor () {
@ -40,7 +40,7 @@ const CarouselItem = props => (
{props.inputs}
<span className='carousel-error'>{props.error}</span>
<div className='button-row'>
<a href='#' onClick={props.onSmallButtonClick}>{props.smallButton}</a>
<a href='#' onClick={e => handleClick(e, props.onSmallButtonClick)}>{props.smallButton}</a>
<button className='btn btn-primary' type='submit' >
{props.button}
</button>

View File

@ -0,0 +1,29 @@
import React from 'react'
import IconButton from '../components/IconButton'
export default class ConfirmIconButton extends React.Component {
constructor () {
super()
this.state = {
confirmed: false
}
this.onClick = this.onClick.bind(this)
}
onClick (e) {
if (this.state.confirmed) {
this.props.onClick(e)
} else {
this.setState({ confirmed: true })
}
}
render () {
const { onClick, icon, ...rest } = this.props
return (
<IconButton
{...rest}
icon={this.state.confirmed ? 'check' : icon}
className={this.state.confirmed ? 'confirm' : ''}
onClick={this.onClick} />
)
}
}

View File

@ -1,29 +1,100 @@
import React from 'react'
import ConfirmIconButton from './ConfirmIconButton'
import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput'
import { removePublisher } from '../actions'
import '../../styles/shared/listitem.scss'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
import Icon from '../components/Icon'
import { removePublisher, setEditingPublisher, saveFile, verifyDomain } from '../actions'
import './listitem.scss'
class PublisherListItem extends React.Component {
constructor () {
super()
this.state = {
revealed: false
}
this.toggleReveal = this.toggleReveal.bind(this)
this.getView = this.getView.bind(this)
this.getEditing = this.getEditing.bind(this)
this.cancelEvent = this.cancelEvent.bind(this)
}
toggleReveal (e) {
e.stopPropagation()
this.setState({
revealed: !this.state.revealed
})
}
cancelEvent (e, id) {
e.stopPropagation()
if (id === false) return
this.props.dispatch(setEditingPublisher(id))
}
getView () {
return (
<li key={this.props.item.appid} className={'uri-list-item publisher-list-item flex-container flex-vertical' + (this.props.item.whitelisted ? ' whitelisted' : '')}>
<header className='site-name flex-container'>
<h3 className='flex'>{`${this.props.item.name}${this.props.item.whitelisted ? '' : ' (awaiting approval)'}`}</h3>
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
</header>
<div className='flex flex-container'>
<div className='col flex flex-container flex-vertical'>
<div className='stack flex-container flex-vertical'>
<span className='label'>AppID</span>
<input className='value' value={this.props.item.appid} readOnly />
</div>
<div className='stack flex-container flex-vertical'>
<span className='label'>Secret</span>
<div className='flex-container'>
<input className='value flex' type={this.state.revealed ? 'text' : 'password'} value={this.props.item.secret} readOnly />
<IconButton className='tiny' onClick={this.toggleReveal} icon={this.state.revealed ? 'eye-close' : 'eye'} />
</div>
</div>
</div>
<div className='col flex flex-container flex-vertical'>
<div className='stack flex-container flex-vertical'>
<span className='label'>Publisher domain</span>
<input className='value' value={this.props.item.url} readOnly />
</div>
<div className='stack flex-container flex-vertical'>
<span className='label'>Domain verification</span>
<div className='verification'>
<Icon icon={this.props.item.verified ? 'shield-check' : 'alert-circle'} className={this.props.item.verified ? 'verified' : 'unverified'} />
{this.props.item.verified && <span className='verified'>Ownership verified</span>}
{!this.props.item.verified && <button className='btn btn-clear' onClick={(e) => this.cancelEvent(e, this.props.item.id)}>Verify domain ownership</button>}
</div>
</div>
</div>
</div>
</li>
)
}
getEditing () {
return (
<li key={this.props.item.appid} className='uri-list-item publisher-list-item flex-container flex-vertical editing' onClick={(e) => this.cancelEvent(e, false)}>
<header className='site-name flex-container'>
<h3 className='flex'>{this.props.item.name}</h3>
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
</header>
<div className='flex flex-container'>
<div className='col flex flex-container flex-vertical'>
<p>
Download <span className='name'>{this.props.item.verification_key}.html</span> and upload it to the root directory of your webserver. Then, click <strong>VERIFY</strong> to verify that you own and control <span className='name'>{this.props.item.url}</span>.
</p>
</div>
<div className='col flex flex-container flex-vertical'>
<div className='flex' />
<button className='btn btn-clear' onClick={() => this.props.dispatch(saveFile(`${this.props.item.verification_key}.html`))}>Download file</button>
<div className='flex' />
<div className='flex-container buttons'>
<span className='cancel flex' onClick={(e) => this.cancelEvent(e, null)}>Cancel</span>
<button className='flex btn' onClick={(e) => this.props.dispatch(verifyDomain(this.props.item.id))}>Verify</button>
</div>
</div>
</div>
</li>
)
}
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>
this.props.editing ? this.getEditing() : this.getView()
)
}
}

View File

@ -3,8 +3,8 @@
import React from 'react'
import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput'
import '../../styles/shared/listitem.scss'
import { changeUrlField, setUrl, removeUrl, setEditing } from '../actions'
import './listitem.scss'
import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/
@ -19,7 +19,7 @@ class UriListItem extends React.Component {
cancelEvent (e, id) {
e.stopPropagation()
if (id === false) return
this.props.dispatch(setEditing(id))
this.props.dispatch(setEditingUri(id))
}
getView () {
return (

View File

@ -1,4 +1,4 @@
@import '../lib/vars';
@import '../../styles/lib/vars';
.carousel-container {
position: relative;

View File

@ -0,0 +1,184 @@
@import '../../styles/lib/vars';
.uri-list-item {
position: relative;
min-height: 60px;
line-height: 60px;
max-width: 100%;
background: white;
box-shadow: $shadow-0;
padding: 0 0 0 14px;
cursor: pointer;
transition: margin 0.15s $transition,
padding 0.15s $transition,
border-radius 0.15s $transition;
.button.icon {
margin: 10px 5px 10px 5px;
}
&.editing {
margin: 24px 8px;
line-height: initial;
padding: 10px 0 10px 14px;
border-radius: 3px;
cursor: default;
header {
h3 {
display: inline-block;
margin: 10px 0 0 0;
font-weight: normal;
cursor: pointer;
}
}
.settings {
padding: 0 14px 0 0;
}
h4 {
margin: 30px 0 0 0;
font-weight: normal;
}
}
.stack {
line-height: 25px;
padding: 5px 0;
> span {
display: inline-block;
width: 100%;
@include ellip;
}
span.label {
font-size: 0.75rem;
color: $black-2;
}
span.value {
margin-top: -4px;
}
}
&.publisher-list-item {
cursor: default;
min-height: 120px;
padding: 10px 0 10px 14px;
.button.icon {
margin: 0;
&.tiny {
padding: 0;
height: 30px;
width: 30px;
}
}
.site-name {
height: 40px;
line-height: 40px;
h3 {
margin: 0;
line-height: 40px;
font-weight: normal;
cursor: default;
}
.button.icon {
margin-right: 10px;
}
}
.stack {
.label {
display: inline-block;
margin-bottom: -7px;
}
input {
display: inline-block;
line-height: 30px;
background: transparent;
}
input.value {
outline: none;
border: none;
font-family: monospace;
margin-right: 10px;
}
}
.col {
margin-right: 10px;
& + .col {
margin-left: 10px;
}
}
.verification {
line-height: 40px;
height: 40px;
.icon {
padding: 0;
height: 30px;
width: 30px;
vertical-align: middle;
margin-right: 10px;
&.verified {
path {
fill: $green;
}
}
&.unverified {
path {
fill: $red;
}
}
}
.btn {
vertical-align: middle;
border: 1px solid $black-4;
line-height: 30px;
height: 30px;
padding: 0 10px;
&:hover {
box-shadow: none;
color: $red;
}
}
span {
color: $green;
}
}
&.editing {
.buttons {
margin-top: 10px;
span.cancel {
display: inline-block;
line-height: 36px;
text-align: right;
padding-right: 10px;
color: $black-2;
cursor: pointer;
}
}
p {
.name {
color: $accent-3;
font-family: monospace;
}
}
}
&:not(.whitelisted) {
&:before {
position: absolute;
content: '';
left: 0;
top: 0;
width: 4px;
height: 100%;
background: $red;
}
}
}
}

View File

@ -6,11 +6,14 @@ import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react
import Progress from './components/Progress'
import UnderlineInput from './components/UnderlineInput'
import UriListItem from './containers/UriListItem'
import PublisherListItem from './containers/PublisherListItem'
import reducer from './reducers'
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
import '../styles/index.scss'
const uriRegex = /^(https?:\/\/)(.+\.)*(.+\.).{1,}(:\d+)?/i
class App extends React.Component {
constructor () {
super()
@ -24,8 +27,9 @@ class App extends React.Component {
},
urls: [],
publishers: [],
newPublisherUrl: '',
newPublisher: { name: '', url: '' },
editingUrl: null,
editingPublisher: 1,
working: false
}
@ -34,7 +38,7 @@ class App extends React.Component {
this.setUserValue = this.setUserValue.bind(this)
this.saveUser = this.saveUser.bind(this)
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
this.setPublisherUrl = this.setPublisherUrl.bind(this)
this.setPublisherValue = this.setPublisherValue.bind(this)
}
dispatch (action) {
if (!action) throw new Error('dispatch: missing action')
@ -51,18 +55,12 @@ class App extends React.Component {
componentDidMount () {
this.dispatch(fetchData())
}
setPublisherUrl (e) {
setPublisherValue (which, 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} />)
newPublisher: {
...this.state.newPublisher,
[which]: e.target.value
}
})
}
setUserValue (which, e) {
@ -84,10 +82,20 @@ class App extends React.Component {
}
})
}
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}
editing={this.state.editingPublisher === item.id}
dispatch={this.dispatch}
item={item} />)
})
@ -105,9 +113,12 @@ class App extends React.Component {
</h2>
</header>
<ul>
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
<li><NavLink to='/targets'>Push URIs</NavLink></li>
<li><NavLink to='/account'>My account</NavLink></li>
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
{(this.state.user.id === 1 || this.state.user.admin) &&
<li><a href='/admin'>Admin</a></li>
}
</ul>
</aside>
<section className={'content flex' + (this.state.working ? ' working' : '')}>
@ -138,11 +149,18 @@ class App extends React.Component {
</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>
className='flex stack-h'
placeholder='Website name'
value={this.state.newPublisher.name}
onChange={e => this.setPublisherValue('name', e)} />
<UnderlineInput
className='flex stack-h'
type='text'
placeholder='Website domain (starts with http or https)'
value={this.state.newPublisher.url}
pattern={uriRegex}
onChange={(e) => this.setPublisherValue('url', e)} />
<button className='btn' onClick={() => this.dispatch(createNewPublisher(this.state.newPublisher))}>Create keys</button>
</div>
<ul className='list'>
{this.getRegisteredPublishers()}
@ -181,7 +199,7 @@ class App extends React.Component {
</div>
)} />
<Route path='/' render={() => <Redirect to='/targets' />} />
<Route path='/' render={() => <Redirect to='/keys' />} />
</Switch>
</section>
</div>

View File

@ -59,12 +59,12 @@ export default class Ajax {
var fd = null
var qs = ''
if (opts.data && opts.method.toLowerCase() !== 'get') {
if (!opts.noProcess && opts.data && opts.method.toLowerCase() !== 'get') {
fd = new FormData()
for (let key in opts.data) {
fd.append(key, opts.data[key])
}
} else if (opts.data) {
} else if (!opts.noProcess && opts.data) {
qs += '?'
let params = []
for (let key in opts.data) {
@ -73,6 +73,14 @@ export default class Ajax {
qs += params.join('&')
}
if (opts.noProcess) {
opts.headers = {
'Content-Type': 'application/json',
...opts.headers
}
try { fd = JSON.stringify(opts.data) } catch (e) { console.warn(e) }
}
xhr.onload = () => {
if (!('' + xhr.status).startsWith('2')) { return xhr.onerror() }
var data = xhr.response

11
assets/js/lib/Util.js Normal file
View File

@ -0,0 +1,11 @@
export default class Util {
static combineReducers (...reducers) {
return function (...reducerParams) {
for (const reduce of reducers) {
const changes = reduce(...reducerParams)
if (changes && Object.keys(changes).length) return changes
}
return {}
}
}
}

View File

@ -0,0 +1,37 @@
'use strict'
import Actions from '../actions/admin'
const reducer = (state = {}, action) => {
const { type, data } = action
let ind
switch (type) {
case Actions.set_working:
return {
working: data
}
case Actions.set_admin_data:
return {
user: data.user,
users: data.users,
publishers: data.publishers
}
case Actions.a_update_user:
const modifiedUsers = [ ...state.users ]
ind = modifiedUsers.findIndex(x => x.id === data.id)
modifiedUsers[ind] = { ...modifiedUsers[ind], ...data }
return {
users: modifiedUsers
}
case Actions.a_update_publisher:
const modifiedPublishers = [ ...state.publishers ]
ind = modifiedPublishers.findIndex(x => x.id === data.id)
modifiedPublishers[ind] = { ...modifiedPublishers[ind], ...data }
return {
publishers: modifiedPublishers
}
default: return {}
}
}
export default reducer

View File

@ -4,7 +4,7 @@ import Actions from '../actions'
const reducer = (state = {}, action) => {
const { type, data } = action
let urls
let urls, ind
switch (type) {
case Actions.set_working:
return {
@ -17,6 +17,10 @@ const reducer = (state = {}, action) => {
...data
}
}
case Actions.set_publishers:
return {
publishers: data
}
case Actions.list_url:
return {
urls: data || []
@ -37,13 +41,17 @@ const reducer = (state = {}, action) => {
return {
urls: urls
}
case Actions.set_editing:
case Actions.set_editing_uri:
return {
editingUrl: data
}
case Actions.set_editing_publisher:
return {
editingPublisher: data
}
case Actions.error:
return {
error: (data || {}).message || ''
error: (data || {}).data ? (data || {}).data : (data || {}).message
}
case Actions.add_publisher:
return {
@ -55,6 +63,14 @@ const reducer = (state = {}, action) => {
publishers: state.publishers.filter(x => x.id !== data),
error: ''
}
case Actions.update_publisher:
const modifiedPublishers = [ ...state.publishers ]
ind = modifiedPublishers.findIndex(x => x.id === data.id)
modifiedPublishers[ind] = { ...modifiedPublishers[ind], ...data }
return {
publishers: modifiedPublishers,
error: ''
}
default: return {}
}
}

94
assets/styles/admin.scss Normal file
View File

@ -0,0 +1,94 @@
@import 'index';
.admin-container {
.list {
li {
padding: 5px 0;
min-height: 50px;
line-height: 50px;
padding: 0 14px;
header {
h3 {
font-weight: normal;
line-height: 30px;
margin: 0;
}
}
.stack {
line-height: 20px;
}
.cb-label {
display: inline-block;
height: 40px;
line-height: 40px;
margin-right: 14px;
cursor: pointer;
user-select: none;
}
input.checkbox[type=checkbox] {
display: none;
+ label {
display: inline-block;
position: relative;
height: 20px;
width: 20px;
margin: 8px 0 12px 0;
vertical-align: middle;
border: 2px solid $black-1;
cursor: pointer;
}
&:checked + label {
&:before {
position: absolute;
content: '';
background: $accent-2;
height: 14px;
width: 14px;
left: 1px;
top: 1px;
}
}
}
.key-value {
span {
line-height: 30px;
&.contains-icon {
margin-bottom: 10px;
}
}
.icon {
padding: 0;
height: 20px;
width: 20px;
vertical-align: middle;
margin-left: 10px;
svg {
height: 20px;
width: 20px;
}
&.verified {
path {
fill: $green;
}
}
&.unverified {
path {
fill: $red;
}
}
}
}
.key {
margin-right: 10px;
}
.value {
font-family: monospace;
color: $black-3;
}
}
}
}

View File

@ -4,9 +4,10 @@
.content {
padding: 14px 0 42px 0;
position: relative;
overflow-y: auto;
.error-box {
height: 30px;
min-height: 30px;
line-height: 30px;
background: $red;
color: white;
@ -28,11 +29,11 @@
margin-top: 4px;
color: $text-dark-2;
text-shadow: 1px 1px 2px $black-4;
}
.creator {
padding: 0 14px;
line-height: 60px;
max-width: 500px;
// max-width: 500px;
.btn {
margin: 12px 0 12px 12px;

View File

@ -35,3 +35,32 @@ $accent-2: #18517c;
$accent-3: #4f91b8;
$red: #FE4C52;
$green: #4FE070;
$small-break: 640px;
$medium-break: 800px;
$large-break: 1200px;
$breakpoints: (
'small': ( max-width: $small-break ),
'medium': ( max-width: $medium-break ),
'large': ( max-width: $large-break )
) !default;
@mixin break($breakpoint) {
@if map-has-key($breakpoints, $breakpoint) {
@media #{inspect(map-get($breakpoints, $breakpoint))} {
@content;
}
}
@else {
@warn "No value could be retrieved from `#{$breakpoint}`. "
+ "Available breakpoints are: #{map-keys($breakpoints)}.";
}
}
@mixin ellip() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -1,58 +0,0 @@
@import '../lib/vars';
.uri-list-item {
min-height: 60px;
line-height: 60px;
background: white;
box-shadow: $shadow-0;
padding: 0 0 0 14px;
cursor: pointer;
transition: margin 0.15s $transition,
padding 0.15s $transition,
border-radius 0.15s $transition;
.button.icon {
margin: 7px 5px 3px 5px;
}
&.editing {
margin: 24px 8px;
line-height: initial;
padding: 10px 0 10px 14px;
border-radius: 3px;
cursor: default;
header {
h3 {
display: inline-block;
margin: 10px 0 0 0;
font-weight: normal;
cursor: pointer;
}
}
.settings {
padding: 0 14px 0 0;
}
h4 {
margin: 30px 0 0 0;
font-weight: normal;
}
}
.stack {
line-height: 25px;
padding: 5px 0;
span {
display: inline-block;
width: 100%;
}
span.label {
font-size: 0.75rem;
color: $black-2;
}
span.value {
margin-top: -4px;
}
}
}

View File

@ -6,7 +6,6 @@
box-shadow: $shadow-1;
header {
height: 50px;
line-height: 50px;
padding: 0 14px;

View File

@ -0,0 +1,18 @@
<% var key, item %>
<% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>River of Ebooks | admin</title>
<base href="/">
<% for (item of htmlWebpackPlugin.options.links) {
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
} %>
<meta name="viewport" content="initial-scale=1, width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no" />
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

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

View File

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

View File

@ -26,6 +26,14 @@ const publishLimiter = rateLimit({
}
})
const allowCrossDomain = function (req, res, next) {
res.header('Access-Control-Allow-Origin', 'http://localhost:8080')
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE')
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
res.header('Access-Control-Allow-Credentials', 'true')
next()
}
module.exports.http = {
/****************************************************************************
@ -47,6 +55,7 @@ module.exports.http = {
***************************************************************************/
order: [
'allowCrossDomain',
'rateLimit',
'publishLimit',
'cookieParser',
@ -63,7 +72,8 @@ module.exports.http = {
rateLimit: rateLimiter,
publishLimit: publishLimiter,
passportInit: require('passport').initialize(),
passportSession: require('passport').session()
passportSession: require('passport').session(),
allowCrossDomain: allowCrossDomain
/***************************************************************************
* *

View File

@ -34,7 +34,7 @@ module.exports.models = {
* *
***************************************************************************/
// schema: true,
schema: true,
/***************************************************************************
* *

View File

@ -42,5 +42,9 @@ module.exports.policies = {
BooksController: {
'*': true,
publish: [ 'keyAuth' ]
},
AdminController: {
'*': [ 'sessionAuth', 'adminAuth' ]
}
}

View File

@ -34,6 +34,12 @@ module.exports.routes = {
// figure out why proper clientside routing breaks the backend session
'GET /account': 'TargetController.show',
'GET /targets': 'TargetController.show',
'GET /keys': 'TargetController.show',
'GET /admin': 'AdminController.show',
'GET /admin/*': {
action: 'admin/show',
skipAssets: true
},
/***************************************************************************
* *
@ -78,7 +84,15 @@ module.exports.routes = {
'POST /api/keys': 'PublishKeyController.create',
'GET /api/keys': 'PublishKeyController.list',
'PATCH /api/keys/:id': 'PublishKeyController.refresh',
'DELETE /api/keys/:id': 'PublishKeyController.delete'
'DELETE /api/keys/:id': 'PublishKeyController.delete',
'POST /api/keys/:id/verify': 'PublishKeyController.verify',
'GET /admin/api/users': 'AdminController.listUsers',
'GET /admin/api/publishers': 'AdminController.listPublishers',
'PATCH /admin/api/users/:id': 'AdminController.editUser',
'PATCH /admin/api/publishers/:id': 'AdminController.editPublisher',
'DELETE /admin/api/users/:id': 'AdminController.deleteUser',
'DELETE /admin/api/publishers/:id': 'AdminController.deletePublisher'
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗

View File

@ -4,7 +4,13 @@
### Publishing a book
```
POST to /api/publish containing the body:
POST to /api/publish containing headers:
{
roe-key: <api key>,
roe-secret: <api secret>
}
and body:
{
title: The book's title,
@ -37,7 +43,7 @@ The server will respond with either:
or
```
400 BAD REQUEST
400 BAD REQUEST / 403 UNAUTHORIZED
{
"error": string,
"hint": string

View File

@ -1,4 +1,3 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {

View File

@ -0,0 +1,29 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('user', t => {
t.boolean('admin').defaultTo(false)
}),
knex.schema.createTable('publishkey', t => {
t.increments('id').primary()
t.integer('user').notNullable().references('user.id').onDelete('CASCADE').onUpdate('CASCADE')
t.string('name')
t.string('url')
t.string('appid')
t.string('secret')
t.string('verification_key')
t.boolean('whitelisted').defaultTo(false)
t.boolean('verified').defaultTo(false)
t.integer('created_at')
t.integer('updated_at')
})
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('user', t => {
t.dropColumns('admin')
}),
knex.schema.dropTable('publishkey')
])
}

View File

@ -5,15 +5,16 @@
"description": "a Sails application",
"keywords": [],
"scripts": {
"start": "npm-run-all --parallel open:client lift",
"start": "npm run forever",
"start:dev": "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",
"start:client": "webpack-dev-server --mode development",
"lift": "sails lift",
"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.'",
@ -59,6 +60,7 @@
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.1",
"file-saver": "^2.0.1",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.4.4",
"mocha": "^5.2.0",

File diff suppressed because it is too large Load Diff

1
views/pages/admin.ejs Normal file
View File

@ -0,0 +1 @@
<%- partial('../../.tmp/public/admin.html') %>

View File

@ -10,11 +10,13 @@ module.exports = (env, argv) => {
mode: mode || 'development',
entry: {
login: './assets/js/login.js',
index: './assets/js/index.js'
index: './assets/js/index.js',
admin: './assets/js/admin.js'
},
output: {
path: path.join(__dirname, '/.tmp/public'),
filename: '[name].bundle.js'
filename: '[name].bundle.js',
publicPath: '/'
},
module: {
rules: [
@ -36,16 +38,22 @@ module.exports = (env, argv) => {
plugins: [
new HtmlWebpackPlugin({
template: 'assets/templates/login.html',
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'login.css' }] : [],
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/login.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/login.html'),
chunks: ['login']
}),
new HtmlWebpackPlugin({
template: 'assets/templates/index.html',
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'index.css' }] : [],
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/index.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/index.html'),
chunks: ['index']
}),
new HtmlWebpackPlugin({
template: 'assets/templates/admin.html',
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/admin.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/admin.html'),
chunks: ['admin']
}),
new MiniCssExtractPlugin({
filename: '[name].css'
}),