Merge pull request #35 from EbookFoundation/staging

merge from staging into new branch
pull/41/head
Theodore Kluge 2019-02-24 13:09:56 -05:00 committed by GitHub
commit bda175b802
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 521 additions and 10580 deletions

View File

@ -115,6 +115,8 @@ module.exports = {
}
passportHelper.callback(req, res, function (err, user, info, status) {
// console.log(err)
// console.log(user)
if (err || !user) {
sails.log.warn(user, err, info, status)
if (!err && info) {
@ -126,6 +128,7 @@ module.exports = {
req.login(user, function (err) {
if (err) {
sails.log.warn(err)
// console.log(err)
return negotiateError(err)
}
@ -134,6 +137,8 @@ module.exports = {
// redirect if there is a 'next' param
if (req.query.next) {
res.status(302).set('Location', req.query.next)
} else if (req.query.code) { // if came from oauth callback
res.status(302).set('Location', '/targets')
}
sails.log.info('user', user, 'authenticated successfully')

View File

@ -6,6 +6,8 @@
*/
const HttpError = require('../errors/HttpError')
const request = require('request')
const uriRegex = /^(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
module.exports = {
publish: async function (req, res) {
@ -22,20 +24,31 @@ module.exports = {
if (bookExists) {
throw new HttpError(400, 'Version already exists')
} else {
result = await Book.create(body).fetch()
const { title, isbn, author, publisher } = body
// require at least 2 fields to be filled out
if ([title, isbn, author, publisher].reduce((a, x) => a + (x ? 1 : 0), 0) >= 2) {
result = await Book.create(body).fetch()
} else {
throw new HttpError(400, 'Please fill out at least 2 fields (title, author, publisher, isbn)')
}
}
req.file('opds').upload(sails.config.skipperConfig, async function (err, uploaded) {
if (err) {
await Book.destroy({ id: result.id })
throw new HttpError(500, err.message)
}
await Book.update({ id: result.id }, { storage: uploaded[0].fd })
sendUpdatesAsync(result.id)
return res.json({
...result
if (req.file('opds')) {
req.file('opds').upload(sails.config.skipperConfig, async function (err, uploaded) {
if (err) {
await Book.destroy({ id: result.id })
throw new HttpError(500, err.message)
}
const fd = (uploaded[0] || {}).fd
await Book.update({ id: result.id }, { storage: fd })
sendUpdatesAsync(result.id)
return res.json({
...result
})
})
})
} else {
throw new HttpError(400, 'Missing OPDS file upload')
}
} catch (e) {
if (e instanceof HttpError) return e.send(res)
return res.status(500).json({
@ -47,9 +60,13 @@ module.exports = {
list: async function (req, res) {
try {
const body = req.allParams()
if (!body) throw new HttpError(400, 'Missing parameters')
const books = await Book.find(body)
let page = 1
const perPage = 200
if (body.page) {
page = Math.abs(+body.page) || 1
delete body.page
}
const books = await Book.find(body || {}).skip((page * perPage) - perPage).limit(perPage)
if (!books.length) {
throw new HttpError(404, 'No books matching those parameters were found.')
@ -65,9 +82,29 @@ module.exports = {
}
async function sendUpdatesAsync (id) {
const book = await Book.find({ id })
const book = await Book.findOne({ id })
const targets = await TargetUrl.find()
if (!book) return
for (const i in targets) {
sails.log('sending ' + book.id + ' info to ' + targets[i].url)
const item = targets[i]
const { author: fAuthor, publisher: fPublisher, title: fTitle, isbn: fIsbn, url } = item
const { author: bAuthor, publisher: bPublisher, title: bTitle, isbn: bIsbn } = book
sails.log('sending ' + book.id + ' info to ' + url)
if (uriRegex.test(url)) {
if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue
if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue
if (fTitle && !((bTitle || '').includes(fTitle))) continue
if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue
request.post({
url: url,
headers: { 'User-Agent': 'RoE-aggregator' },
form: book
}, function (err, httpResp, body) {
if (err) {
sails.log(`error: failed to send book ${id} to ${url}`)
}
})
}
}
}

View File

@ -3,7 +3,7 @@ const HttpError = require('../errors/HttpError')
module.exports = {
show: function (req, res) {
res.view('pages/targets', {
res.view('pages/app', {
email: req.user.email
})
},
@ -21,10 +21,10 @@ module.exports = {
try {
const id = req.param('id')
const value = req.param('url')
const author = req.param('author')
const publisher = req.param('publisher')
const title = req.param('title')
const isbn = req.param('isbn')
const author = req.param('author') || ''
const publisher = req.param('publisher') || ''
const title = req.param('title') || ''
const isbn = req.param('isbn') || ''
if (value.length) {
const url = await TargetUrl.update({ id, user: req.user.id }, {
url: value,

View File

@ -17,12 +17,11 @@ module.exports = {
error: err.toString()
})
}
res.json(user)
})
},
update: async function (req, res, next) {
edit: async function (req, res, next) {
const passportHelper = await sails.helpers.passport()
passportHelper.protocols.local.update(req.body, function (err, user) {
if (err) {
@ -30,7 +29,6 @@ module.exports = {
error: err.toString()
})
}
res.json(user)
})
},

View File

@ -48,8 +48,8 @@ function PassportHelper () {
const protocol = strategies[key].protocol
const callbackURL = strategies[key].callback
let baseURL = ''
if (sails.config.appUrl && sails.config.appUrl !== null) {
baseURL = sails.config.appUrl
if (sails.config.custom.baseURL && sails.config.custom.baseURL !== null) {
baseURL = sails.config.custom.baseURL
} else {
sails.log.warn('Please add \'appUrl\' to configuration')
baseURL = sails.getBaseurl()
@ -74,7 +74,12 @@ function PassportHelper () {
if (!_.has(strategies, provider)) return res.redirect('/login')
passport.authenticate(provider, {})(req, res, req.next)
const scopes = {
google: ['email'],
github: ['user:email']
}
passport.authenticate(provider, { scope: scopes[provider] })(req, res, req.next)
}
// a callback helper to split by req
this.callback = function (req, res, next) {
@ -112,33 +117,39 @@ function PassportHelper () {
// if the profile object from passport has an email, use it
if (profile.emails && profile.emails[0]) userAttrs.email = profile.emails[0].value
if (!userAttrs.email) return next(new Error('No email available'))
// if (!userAttrs.email) return next(new Error('No email available'))
const pass = await Passport.findOne({
provider,
identifier: q.identifier.toString()
identifier: q.identifier
})
let user
if (!req.user) {
if (!pass) { // new user signing up, create a new user
user = await User.create(userAttrs).fetch()
if (!pass) { // new user signing up, create a new user and/or passport
if (userAttrs.email) {
user = await User.findOne({ email: userAttrs.email })
}
if (!user) {
user = await User.create(userAttrs).fetch()
}
await Passport.create({
...q,
user: user.id
})
next(null, user)
} else { // existing user logging in
if (_.has(q, 'tokens') && q.tokens !== passport.tokens) {
passport.tokens = q.tokens
if (_.has(q, 'tokens') && q.tokens !== pass.tokens) {
pass.tokens = q.tokens
}
await passport.save()
user = User.findOne(passport.user)
next(null, user)
delete pass.id
await Passport.update({ id: pass.id }, { tokens: pass.tokens })
user = await User.find({ id: passport.user }).limit(1)
next(null, user[0])
}
} else { // user logged in and trying to add new Passport
if (!passport) {
if (!pass) {
await Passport.create({
...q,
user: req.user.id

View File

@ -15,13 +15,14 @@ module.exports = {
id: {
type: 'number',
unique: true,
autoIncrement: true,
columnName: '_id'
autoIncrement: true
},
title: { type: 'string', required: true },
author: { type: 'string' },
publisher: { type: 'string' },
isbn: { type: 'string' },
version: { type: 'string' }
version: { type: 'string' },
hostname: { type: 'string' }
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗

View File

@ -28,8 +28,7 @@ module.exports = {
id: {
type: 'number',
unique: true,
autoIncrement: true,
columnName: '_id'
autoIncrement: true
},
// local, oauth2, etc
protocol: {

View File

@ -14,13 +14,10 @@ module.exports = {
id: {
type: 'number',
unique: true,
autoIncrement: true,
columnName: '_id'
autoIncrement: true
},
email: {
type: 'string',
unique: true,
required: true
type: 'string'
}
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗

View File

@ -9,9 +9,10 @@ const ACTIONS = {
delete_url: 'delete_url',
list_url: 'list_url',
set_editing: 'set_editing',
error: 'error',
set_user: 'set_user',
add_publisher: 'add_publisher',
delete_publisher: 'delete_publisher',
error: 'error'
delete_publisher: 'delete_publisher'
}
export default ACTIONS
@ -26,6 +27,11 @@ export const setUrls = (urls) => ({
data: urls
})
export const setUser = user => ({
type: ACTIONS.set_user,
data: user
})
export const addUrl = url => ({
type: ACTIONS.add_url,
data: url
@ -70,13 +76,17 @@ export const removeUrl = id => async (dispatch, getState) => {
}
}
export const fetchUrls = () => async (dispatch, getState) => {
export const fetchData = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.get({
const { data: user } = await Ajax.get({
url: '/api/me'
})
dispatch(setUser(user))
const { data: urls } = await Ajax.get({
url: '/api/targets'
})
dispatch(setUrls(data))
dispatch(setUrls(urls))
} catch (e) {
dispatch({
type: ACTIONS.error,
@ -124,6 +134,34 @@ export const setUrl = (value) => async (dispatch, getState) => {
}
}
export const editUser = (user) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
// if (!user.currentPassword) throw new Error('Please enter your current password.')
await Ajax.patch({
url: '/api/me',
data: {
id: user.id,
email: user.email,
password: user.password,
currentPassword: user.currentPassword
}
})
dispatch({
type: ACTIONS.error,
data: null
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const createNewPublisher = (url) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {

View File

@ -47,7 +47,7 @@ export const clearError = () => ({
export const setLoggedIn = (data) => (dispatch, getState) => {
window.localStorage.setItem('roe-token', JSON.stringify(data))
window.location.href = '/app'
window.location.href = '/targets'
}
export const checkEmail = email => async (dispatch, getState) => {
@ -94,7 +94,7 @@ export const checkPassword = (email, password) => async (dispatch, getState) =>
} catch (e) {
dispatch(setError({
type: 'password',
error: e.toString()
error: e.message
}))
dispatch(setWorking(false))
}
@ -122,7 +122,7 @@ export const signup = (email, password) => async (dispatch, getState) => {
} catch (e) {
dispatch(setError({
type: 'email',
error: e.toString()
error: e.message
}))
}
} else {

View File

@ -45,9 +45,11 @@ const CarouselItem = props => (
{props.button}
</button>
</div>
{props.footer &&
{props.footers && props.footers.length &&
<footer className='footer-row'>
<a href='#'>{props.footer}</a>
{
props.footers.map((x, i) => <a key={i} href={props.footerHrefs[i] || '#'}>{x}</a>)
}
</footer>}
</form>
)

View File

@ -7,7 +7,8 @@ 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 { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
import '../styles/index.scss'
class App extends React.Component {
@ -16,8 +17,10 @@ class App extends React.Component {
this.state = {
error: '',
user: {
id: '',
email: '',
password: ''
password: '',
currentPassword: ''
},
urls: [],
publishers: [],
@ -28,6 +31,8 @@ class App extends React.Component {
this.dispatch = this.dispatch.bind(this)
this.getRegisteredUris = this.getRegisteredUris.bind(this)
this.setUserValue = this.setUserValue.bind(this)
this.saveUser = this.saveUser.bind(this)
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
this.setPublisherUrl = this.setPublisherUrl.bind(this)
}
@ -44,7 +49,7 @@ class App extends React.Component {
}
}
componentDidMount () {
// this.dispatch(fetchUrls())
this.dispatch(fetchData())
}
setPublisherUrl (e) {
this.setState({
@ -60,6 +65,25 @@ class App extends React.Component {
editing={this.state.editingUrl === item.id} />)
})
}
setUserValue (which, e) {
this.setState({
user: {
...this.state.user,
[which]: e.target.value
}
})
}
saveUser () {
this.dispatch(editUser(this.state.user))
this.setState({
user: {
...this.state.user,
email: this.state.user.email,
password: '',
currentPassword: ''
}
})
}
getRegisteredPublishers () {
return this.state.publishers.map((item, i) => {
return (<PublisherListItem
@ -75,16 +99,35 @@ class App extends React.Component {
<aside className='nav nav-left'>
<header>
<h1>River of Ebooks</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='/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>
</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='/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='/keys' exact children={props => (
<div>
<header className='flex-container'>
@ -107,22 +150,38 @@ class App extends React.Component {
</div>
)} />
<Route path='/targets' exact children={props => (
<Route path='/account' 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>
<h1>My account</h1>
<h2>User account settings</h2>
</div>
<button className='btn' onClick={() => this.dispatch(createNewUrl())}>New address</button>
</header>
<ul className='list'>
{this.getRegisteredUris()}
</ul>
<section className='inputs'>
<UnderlineInput
placeholder='Email address'
value={this.state.user.email}
pattern={/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/}
onChange={(e) => this.setUserValue('email', e)} />
<UnderlineInput
placeholder='Password'
type='password'
value={this.state.user.password}
onChange={(e) => this.setUserValue('password', e)} />
<UnderlineInput
placeholder='Current password'
type='password'
value={this.state.user.currentPassword}
onChange={(e) => this.setUserValue('currentPassword', e)} />
<div className='buttons'>
<button className='btn' onClick={this.saveUser}>Save</button>
</div>
</section>
</div>
)} />
<Route path='/' render={() => <Redirect to='/keys' />} />
<Route path='/' render={() => <Redirect to='/targets' />} />
</Switch>
</section>
</div>

View File

@ -86,28 +86,29 @@ export default class Ajax {
}
xhr.onerror = () => {
var data = xhr.response
try { data = JSON.parse(data) } catch (e) {}
// method not allowed
if (xhr.status === 405) {
reject(new AjaxError('405 Method Not Allowed', data, xhr))
reject(new AjaxError('405 Method Not Allowed', data.error || data, xhr))
return
} else if (xhr.status === 404) {
reject(new AjaxError('404 Not Found', data, xhr))
reject(new AjaxError('404 Not Found', data.error || data, xhr))
return
}
try {
// if the access token is invalid, try to use the refresh token
var json = JSON.parse(data)
var json = data
if (json.error === 'access_denied' && json.hint.includes('token') && json.hint.includes('invalid') && ajaxcfg.refresh_token) {
return Ajax.refresh(opts)
} else if (json.error === 'access_denied' && json.hint.includes('token') && json.hint.includes('revoked')) {
reject(new AjaxError('token revoked', data, xhr))
}
} catch (e) {
reject(new AjaxError(e.toString(), data, xhr))
reject(new AjaxError(e.toString(), data.error || data, xhr))
} finally {
reject(new AjaxError(data, xhr.status, xhr))
reject(new AjaxError(data.error || data, xhr.status, xhr))
}
}

View File

@ -87,7 +87,8 @@ class App extends React.Component {
onButtonClick={() => this.dispatch(signup(this.state.user.email, this.state.user.password))}
smallButton='Have an account?'
onSmallButtonClick={() => this.dispatch(setCarousel(1))}
footer='Sign up with your Google account' />
footers={['Sign up with Google', 'Sign up with Github']}
footerHrefs={['/auth/google', '/auth/github']} />
<CarouselItem
header='Sign in'
@ -97,7 +98,8 @@ class App extends React.Component {
onButtonClick={() => this.dispatch(checkEmail(this.state.user.email))}
smallButton='Create account'
onSmallButtonClick={() => this.dispatch(setCarousel(0))}
footer='Sign in with your Google account' />
footers={['Sign in with Google', 'Sign in with Github']}
footerHrefs={['/auth/google', '/auth/github']} />
<CarouselItem
header='Welcome'
@ -106,8 +108,8 @@ class App extends React.Component {
error={this.state.passwordError}
button='Sign in'
onButtonClick={() => this.dispatch(checkPassword(this.state.user.email, this.state.user.password))}
smallButton='Forgot password?'
onSmallButtonClick={() => this.dispatch(setCarousel(3))} />
comment={null/*smallButton='Forgot password?'
onSmallButtonClick={() => this.dispatch(setCarousel(3))}*/} />
<CarouselItem
header='Password recovery'

View File

@ -10,6 +10,13 @@ const reducer = (state = {}, action) => {
return {
working: data
}
case Actions.set_user:
return {
user: {
...state.user,
...data
}
}
case Actions.list_url:
return {
urls: data || []
@ -34,6 +41,10 @@ const reducer = (state = {}, action) => {
return {
editingUrl: data
}
case Actions.error:
return {
error: (data || {}).message || ''
}
case Actions.add_publisher:
return {
publishers: state.publishers.concat(data),
@ -44,10 +55,6 @@ const reducer = (state = {}, action) => {
publishers: state.publishers.filter(x => x.id !== data),
error: ''
}
case Actions.error:
return {
error: data.message
}
default: return {}
}
}

View File

@ -14,23 +14,21 @@
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;
}
}
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;
@ -47,6 +45,14 @@
list-style: none;
// overflow: hidden;
}
.inputs {
padding: 20px 14px;
.buttons {
margin-top: 14px;
text-align: right;
}
}
&.working {
& > .progress {
top: 0;

View File

@ -30,8 +30,8 @@ $text-dark-2: $black-2;
$text-light-1: $white-1;
$text-light-2: $white-2;
$accent-1: #731212;
$accent-2: #9a834d;
$accent-3: #D4DBF1;
$accent-1: #102237;
$accent-2: #18517c;
$accent-3: #4f91b8;
$red: #FE4C52;

View File

@ -83,6 +83,7 @@
bottom: 20px;
width: 100%;
margin: 0;
line-height: 30px;
color: $text-dark-2;
.btn {
@ -91,6 +92,8 @@
a {
color: $accent-2;
text-decoration: none;
display: inline-block;
width: 100%;
}
}
.button-row {

View File

@ -9,6 +9,20 @@
height: 50px;
line-height: 50px;
padding: 0 14px;
h2 {
margin: -10px 0 0 0;
padding: 0;
font-weight: normal;
font-size: 12pt;
height: 36px;
line-height: 36px;
color: $white-2;
}
a {
text-decoration: none;
color: $accent-3;
}
}
ul {
list-style: none;

View File

@ -347,7 +347,7 @@ module.exports = {
* *
***************************************************************************/
custom: {
baseUrl: 'https://example.com',
baseURL: 'http://localhost:3000',
internalEmailAddress: 'support@example.com'
// mailgunDomain: 'mg.example.com',

View File

@ -348,7 +348,7 @@ module.exports = {
* *
***************************************************************************/
custom: {
baseUrl: 'https://example.com',
baseURL: 'http://ec2-18-219-223-27.us-east-2.compute.amazonaws.com',
internalEmailAddress: 'support@example.com'
// mailgunDomain: 'mg.example.com',

View File

@ -14,7 +14,15 @@ const rateLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 100, // limit each IP to 100 requests per windowMs
skip (req, res) {
return !req.path.startsWith('/api')
return !req.path.startsWith('/api') || req.path.startsWith('/api/publish')
}
})
const publishLimiter = rateLimit({
windowMs: 1000 * 60 * 60 * 24, // 24 hours
max: 1000, // 1000 publish requests per day
skip (req, res) {
return !req.path.startsWith('/api/publish')
}
})
@ -40,6 +48,7 @@ module.exports.http = {
order: [
'rateLimit',
'publishLimit',
'cookieParser',
'session',
'passportInit',
@ -52,6 +61,7 @@ module.exports.http = {
'favicon'
],
rateLimit: rateLimiter,
publishLimit: publishLimiter,
passportInit: require('passport').initialize(),
passportSession: require('passport').session()

View File

@ -5,5 +5,17 @@
module.exports.passport = {
local: {
strategy: require('passport-local').Strategy
},
google: {
strategy: require('passport-google-oauth20').Strategy,
protocol: 'oauth2',
callback: '/auth/google/callback',
options: {}
},
github: {
strategy: require('passport-github2').Strategy,
protocol: 'oauth2',
callback: '/auth/github/callback',
options: {}
}
}

View File

@ -66,7 +66,52 @@ module.exports.protocols = {
}
},
update: async function (user, next) {
throw new Error('not implemented')
try {
const dbUser = await User.findOne({
id: user.id
})
if (!dbUser) throw new Error('An account with that id was not found.')
const passport = await Passport.findOne({
protocol: 'local',
user: user.id
})
if (!user.currentPassword && passport) throw new Error('Please enter your current password.')
if (passport) {
const res = await Passport.validatePassword(user.currentPassword, passport)
if (!res) throw new Error('incorrect password')
const otherUser = await User.findOne({ email: user.email })
if (otherUser && otherUser.id !== dbUser.id) throw new Error('There is already an account with that email.')
await User.update({ id: user.id }, {
email: user.email
})
if (user.password && user.password.length) {
await Passport.update({ id: passport.id }, {
password: user.password
})
}
} else { // no password yet, add one
const otherUser = await User.findOne({ email: user.email })
if (otherUser && otherUser.id !== dbUser.id) throw new Error('There is already an account with that email.')
await User.update({ id: user.id }, {
email: user.email
})
if (user.password && user.password.length) {
const token = generateToken()
await Passport.create({
protocol: 'local',
password: user.password,
user: dbUser.id,
accesstoken: token
})
}
}
delete dbUser.password
next(null, dbUser)
} catch (e) {
return next(e)
}
},
connect: async function (req, res, next) {
try {
@ -90,6 +135,23 @@ module.exports.protocols = {
return next(e)
}
}
},
oauth2: {
login: async function (req, accessToken, refreshToken, profile, next) {
try {
const passportHelper = await sails.helpers.passport()
await passportHelper.connect(req, {
tokens: {
accessToken,
refreshToken
},
identifier: profile.id,
protocol: 'oauth2'
}, profile, next)
} catch (e) {
return next(e, false)
}
}
}
}

View File

@ -31,7 +31,9 @@ module.exports.routes = {
'GET /register': {
view: 'pages/login'
},
'GET /app': 'TargetController.show',
// figure out why proper clientside routing breaks the backend session
'GET /account': 'TargetController.show',
'GET /targets': 'TargetController.show',
/***************************************************************************
* *
@ -55,6 +57,8 @@ module.exports.routes = {
'POST /auth/email_available': 'AuthController.emailAvailable',
// 'POST /auth/local': 'AuthController.callback',
// 'POST /auth/local/:action': 'AuthController.callback',
'GET /api/me': 'UserController.me',
'PATCH /api/me': 'UserController.edit',
'POST /auth/:provider': 'AuthController.callback',
'POST /auth/:provider/:action': 'AuthController.callback',
@ -64,9 +68,7 @@ module.exports.routes = {
'GET /auth/:provider/:action': 'AuthController.callback',
'POST /api/publish': 'BooksController.publish',
'GET /api/books': 'BooksController.list',
'GET /api/me': 'UserController.me',
'POST /api/targets': 'TargetController.create',
'GET /api/targets': 'TargetController.list',

112
docs/api.md Normal file
View File

@ -0,0 +1,112 @@
# River of Ebooks REST API
## Information on how to use the api endpoints to publish and view ebook metadata
### Publishing a book
```
POST to /api/publish containing the body:
{
title: The book's title,
author: The author (optional),
version: A version number (optional),
isbn: The ISBN (optional),
opds: file
}
```
Each tuple of `(title, author, version, isbn)` must be unique.
The `opds` parameter is an opds file sent along with the post body.
The server will respond with either:
```
200 OK
{
"created_at": 1550102480021,
"updated_at": 1550102480021,
"id": number,
"title": string,
"author": string,
"isbn": string,
"version": string
}
```
or
```
400 BAD REQUEST
{
"error": string,
"hint": string
}
```
### Fetching published books
GET from /api/books with the query string parameters:
```
title: The book's title (optional)
author: The author (optional)
version: A version number (optional)
isbn: The ISBN (optional)
page: The page of results to view (200 results per page)
```
For example: `GET /api/books?title=foo&page=3`
The server will respond with either:
```
200 OK
[
{
"storage": "path/to/opds/storage/location",
"created_at": timestamp,
"updated_at": timestamp,
"id": number,
"title": string,
"author": string,
"isbn": string,
"version": string
}
]
```
or
```
404 NOT FOUND
{
"error": string,
"hint": string
}
```
### Receiving push notifications to your webhooks:
- Log in to the River of Ebooks website
- Add your webhook URL and desired filters
The server will send a POST request to the provided URL whenever a new ebook is published through the pipeline with the following data:
```
HTTP Headers:
User-Agent: RoE-aggregator
HTTP Body:
{
"storage": "path/to/opds/storage/location",
"created_at": timestamp,
"updated_at": timestamp,
"id": number,
"title": string,
"author": string,
"isbn": string,
"version": string
}
```

View File

@ -0,0 +1,15 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.string('publisher')
})
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.dropColumns('publisher')
})
])
}

View File

@ -0,0 +1,16 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.string('hostname')
})
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {
t.dropColumns('hostname')
})
])
}

View File

@ -21,7 +21,8 @@
"debug": "node --inspect app.js",
"custom-tests": "echo 'Nothing yet'",
"db:migrate": "knex migrate:latest",
"db:rollback": "knex migrate:rollback"
"db:rollback": "knex migrate:rollback",
"g:migration": "knex migrate:make"
},
"dependencies": {
"@sailshq/connect-redis": "^3.2.1",
@ -42,10 +43,12 @@
"pm2": "^3.2.2",
"react": "^16.6.0",
"react-dom": "^16.6.0",
"request": "^2.88.0",
"sails": "^1.0.2",
"sails-hook-grunt": "^3.0.2",
"sails-hook-orm": "^2.1.1",
"sails-hook-sockets": "^1.4.0"
"sails-hook-sockets": "^1.4.0",
"sails-postgresql": "^1.0.2"
},
"devDependencies": {
"@babel/core": "^7.1.2",

File diff suppressed because it is too large Load Diff

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

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

View File

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