this.dispatch(setPassword(e.target.value))}
63 | value={this.state.user.password} />
64 | ]
65 | }
66 | getPasswordHeader () {
67 | return (
68 |
74 | )
75 | }
76 | render () {
77 | return (
78 |
79 |
80 |
81 |
82 | this.dispatch(signup(this.state.user.email, this.state.user.password))}
88 | smallButton='Have an account?'
89 | onSmallButtonClick={() => this.dispatch(setCarousel(1))}
90 | footers={['Sign up with Google', 'Sign up with Github']}
91 | footerHrefs={['/auth/google', '/auth/github']} />
92 |
93 | this.dispatch(checkEmail(this.state.user.email))}
99 | smallButton='Create account'
100 | onSmallButtonClick={() => this.dispatch(setCarousel(0))}
101 | footers={['Sign in with Google', 'Sign in with Github']}
102 | footerHrefs={['/auth/google', '/auth/github']} />
103 |
104 | this.dispatch(checkPassword(this.state.user.email, this.state.user.password))}
111 | comment={null/*smallButton='Forgot password?'
112 | onSmallButtonClick={() => this.dispatch(setCarousel(3))}*/} />
113 |
114 | null}
120 | smallButton='Log in'
121 | onSmallButtonClick={() => this.dispatch(setCarousel(1))} />
122 |
123 |
124 |
125 | )
126 | }
127 | }
128 |
129 | ReactDOM.render( , document.getElementById('root'))
130 |
--------------------------------------------------------------------------------
/api/controllers/AuthController.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Authentication Controller
3 | */
4 | // some also from https://github.com/trailsjs/sails-auth
5 |
6 | module.exports = {
7 |
8 | /**
9 | * check if the given email has a corresponding user
10 | */
11 | emailExists: async function (req, res) {
12 | const user = await User.findOne({
13 | email: req.param('email')
14 | })
15 | if (!user) {
16 | return res.status(404).json({
17 | error: 'user does not exist'
18 | })
19 | } else {
20 | return res.json({
21 | status: 'ok'
22 | })
23 | }
24 | },
25 | /**
26 | * opposite of emailExists
27 | */
28 | emailAvailable: async function (req, res) {
29 | const user = await User.findOne({
30 | email: req.param('email')
31 | })
32 | if (user) {
33 | return res.status(401).json({
34 | error: 'that email address is not available'
35 | })
36 | } else {
37 | return res.json({
38 | status: 'ok'
39 | })
40 | }
41 | },
42 |
43 | /**
44 | * Log out a user and return them to the homepage
45 | *
46 | * Passport exposes a logout() function on req (also aliased as logOut()) that
47 | * can be called from any route handler which needs to terminate a login
48 | * session. Invoking logout() will remove the req.user property and clear the
49 | * login session (if any).
50 | *
51 | * For more information on logging out users in Passport.js, check out:
52 | * http://passportjs.org/guide/logout/
53 | *
54 | * @param {Object} req
55 | * @param {Object} res
56 | */
57 | logout: function (req, res) {
58 | req.logout()
59 | delete req.user
60 | delete req.session.passport
61 | req.session.authenticated = false
62 |
63 | if (!req.isSocket) {
64 | res.redirect(req.query.next || '/')
65 | } else {
66 | res.ok()
67 | }
68 | },
69 |
70 | /**
71 | * Create a third-party authentication endpoint
72 | *
73 | * @param {Object} req
74 | * @param {Object} res
75 | */
76 | provider: async function (req, res) {
77 | const passportHelper = await sails.helpers.passport()
78 | passportHelper.endpoint(req, res)
79 | },
80 |
81 | /**
82 | * Create a authentication callback endpoint
83 | *
84 | * This endpoint handles everything related to creating and verifying Pass-
85 | * ports and users, both locally and from third-aprty providers.
86 | *
87 | * Passport exposes a login() function on req that
88 | * can be used to establish a login session. When the login operation
89 | * completes, user will be assigned to req.user.
90 | *
91 | * For more information on logging in users in Passport.js, check out:
92 | * http://passportjs.org/guide/login/
93 | *
94 | * @param {Object} req
95 | * @param {Object} res
96 | */
97 | callback: async function (req, res) {
98 | const action = req.param('action')
99 | const passportHelper = await sails.helpers.passport()
100 |
101 | function negotiateError (err) {
102 | if (action === 'register') {
103 | res.redirect('/register')
104 | } else if (action === 'login') {
105 | res.redirect('/login')
106 | } else if (action === 'disconnect') {
107 | res.redirect('back')
108 | } else {
109 | // make sure the server always returns a response to the client
110 | // i.e passport-local bad username/email or password
111 | res.status(401).json({
112 | 'error': err.toString()
113 | })
114 | }
115 | }
116 |
117 | passportHelper.callback(req, res, function (err, user, info, status) {
118 | // console.log(err)
119 | // console.log(user)
120 | if (err || !user) {
121 | sails.log.warn(user, err, info, status)
122 | if (!err && info) {
123 | return negotiateError(info)
124 | }
125 | return negotiateError(err)
126 | }
127 |
128 | req.login(user, function (err) {
129 | if (err) {
130 | sails.log.warn(err)
131 | // console.log(err)
132 | return negotiateError(err)
133 | }
134 |
135 | req.session.authenticated = true
136 |
137 | // redirect if there is a 'next' param
138 | if (req.query.next) {
139 | res.status(302).set('Location', req.query.next)
140 | } else if (req.query.code) { // if came from oauth callback
141 | res.status(302).set('Location', '/keys')
142 | }
143 |
144 | sails.log.info('user', user, 'authenticated successfully')
145 | return res.json(user)
146 | })
147 | })
148 | },
149 |
150 | /**
151 | * Disconnect a passport from a user
152 | *
153 | * @param {Object} req
154 | * @param {Object} res
155 | */
156 | disconnect: async function (req, res) {
157 | const passportHelper = await sails.helpers.passport()
158 | passportHelper.disconnect(req, res)
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/assets/js/containers/UriListItem.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | import React from 'react'
4 | import ConfirmIconButton from '../containers/ConfirmIconButton'
5 | import UnderlineInput from '../components/UnderlineInput'
6 | import './listitem.scss'
7 | import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
8 |
9 | const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
10 | // const isbnRegex = /^(97(8|9))?\d{9}(\d|X)$/
11 |
12 | class UriListItem extends React.Component {
13 | constructor () {
14 | super()
15 | this.getView = this.getView.bind(this)
16 | this.getEditing = this.getEditing.bind(this)
17 | this.cancelEvent = this.cancelEvent.bind(this)
18 | }
19 | cancelEvent (e, id) {
20 | e.stopPropagation()
21 | if (id === false) return
22 | this.props.dispatch(setEditingUri(id))
23 | }
24 | getView () {
25 | return (
26 | this.cancelEvent(e, this.props.item.id)}>
27 |
28 | Destination URL
29 | {this.props.item.url}
30 |
31 |
32 | Filters
33 | {['publisher', 'title', 'author', 'isbn', 'tags'].reduce((a, x) => a + (this.props.item[x] ? 1 : 0), 0) || 'None'}
34 |
35 | this.props.dispatch(removeUrl(this.props.item.id))} />
36 |
37 | )
38 | }
39 | getEditing () {
40 | return (
41 | this.cancelEvent(e, false)}>
42 | this.cancelEvent(e, null)}>
43 | Editing: {this.props.item.url}
44 | this.props.dispatch(removeUrl(this.props.item.id))} />
45 |
46 |
47 | this.props.dispatch(changeUrlField(this.props.item.id, 'url', e.target.value))}
55 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
56 | Filters
57 | this.props.dispatch(changeUrlField(this.props.item.id, 'title', e.target.value))}
64 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
65 | this.props.dispatch(changeUrlField(this.props.item.id, 'author', e.target.value))}
72 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
73 | this.props.dispatch(changeUrlField(this.props.item.id, 'publisher', e.target.value))}
80 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
81 | this.props.dispatch(changeUrlField(this.props.item.id, 'isbn', e.target.value))}
88 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
89 | this.props.dispatch(changeUrlField(this.props.item.id, 'tags', e.target.value.split(/,\s+/)))}
97 | onBlur={(e) => this.props.dispatch(setUrl(this.props.item))} />
98 |
99 |
100 | )
101 | }
102 | render () {
103 | return (
104 | this.props.editing ? this.getEditing() : this.getView()
105 | )
106 | }
107 | }
108 |
109 | export default UriListItem
110 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # River of Ebooks REST API
2 | ## Information on how to use the api endpoints to publish and view ebook metadata
3 |
4 | ### Publishing ebook metadata
5 |
6 | ```
7 | POST to /api/publish containing headers:
8 | {
9 | roe-key: ,
10 | roe-secret:
11 | }
12 |
13 | and opds2 publication body with type `application/json`:
14 |
15 | {
16 | "metadata": {
17 | "@type": "http://schema.org/Book",
18 | "title": "Moby-Dick",
19 | "author": "Herman Melville",
20 | "identifier": "urn:isbn:978031600000X",
21 | "tags": "story,classic",
22 | "publisher": "Ebook Publisher.com",
23 | "language": "en",
24 | "modified": "2015-09-29T17:00:00Z"
25 | },
26 | "links": [
27 | {"rel": "self", "href": "http://example.org/manifest.json", "type": "application/webpub+json"}
28 | ],
29 | "images": [
30 | {"href": "http://example.org/cover.jpg", "type": "image/jpeg", "height": 1400, "width": 800},
31 | {"href": "http://example.org/cover-small.jpg", "type": "image/jpeg", "height": 700, "width": 400},
32 | {"href": "http://example.org/cover.svg", "type": "image/svg+xml"}
33 | ]
34 | }
35 | ```
36 |
37 | @Type must be `http://schema.org/Book`.
38 | Each tuple of `(title, author, publisher, identifier, modified)` must be unique.
39 |
40 | The server will respond with either:
41 |
42 | ```
43 | 200 OK
44 | {
45 | "created_at": 1550102480021,
46 | "updated_at": 1550102480021,
47 | "id": number,
48 | "title": string,
49 | "author": string,
50 | "tags": array,
51 | "publisher": string,
52 | "identifier": string,
53 | "version": string,
54 | "opds": json
55 | }
56 | ```
57 |
58 | or
59 |
60 | ```
61 | 400 BAD REQUEST / 403 UNAUTHORIZED
62 | {
63 | "error": string,
64 | "hint": string
65 | }
66 | ```
67 |
68 | ### Fetching published books
69 |
70 | GET from /api/catalog/all with the query string parameters:
71 |
72 | ```
73 | title: The ebook's title (optional)
74 | author: The author (optional)
75 | version: A version number (optional)
76 | isbn: The ISBN (optional)
77 | tags: Comma-separated search tags (optional)
78 |
79 | page: The page of results to view (200 results per page)
80 | ```
81 |
82 | For example: `GET /api/catalog/all?title=foo&page=3`
83 |
84 | The server will respond with either:
85 |
86 | ```
87 | 200 OK
88 | {
89 | "metadata":{
90 | "title": "RoE all publications",
91 | "itemsPerPage": 200,
92 | "currentPage": 1
93 | },
94 | "links":[
95 | {
96 | "rel": "self",
97 | "href": "all?page=1",
98 | "type": "application/opds+json"
99 | }
100 | {
101 | "rel": "search",
102 | "href": "all{?title,author,version,isbn}",
103 | "type": "application/opds+json",
104 | "templated": true
105 | }
106 | ],
107 | "publications":[
108 | {
109 | "metadata":{
110 | "@type": "http://schema.org/Book",
111 | "title": "Moby-Dick",
112 | "author": "Herman Melville",
113 | "tags": "story,classic",
114 | "publisher": "Ebook Publisher.com",
115 | "identifier": "urn:isbn:978031600000X",
116 | "language": "en",
117 | "modified": "2015-09-29T17:00:00Z"
118 | },
119 | "links":[
120 | {
121 | "rel": "self",
122 | "href": "http://example.org/manifest.json",
123 | "type": "application/webpub+json"
124 | }
125 | ],
126 | "images":[
127 | {
128 | "href": "http://example.org/cover.jpg",
129 | "type": "image/jpeg",
130 | "height": 1400,
131 | "width": 800
132 | },
133 | {
134 | "href": "http://example.org/cover.svg",
135 | "type": "image/svg+xml"
136 | }
137 | ]
138 | }
139 | ]
140 | }
141 | ```
142 |
143 | or
144 |
145 | ```
146 | 404 NOT FOUND
147 | {
148 | "error": string,
149 | "hint": string
150 | }
151 | ```
152 |
153 | ### Receiving push notifications to your webhooks:
154 |
155 | - Log in to the River of Ebooks website
156 | - Add your webhook URL and desired filters
157 |
158 | The server will send a POST request with the following body to the provided URL whenever a new ebook is published through the pipeline:
159 |
160 | ```
161 | HTTP Headers:
162 | User-Agent: RoE-aggregator
163 | X-Roe-Request-Timestamp: number
164 | X-Roe-Signature: string
165 |
166 | HTTP Body:
167 | {
168 | "metadata":{
169 | "@type": "http://schema.org/Book",
170 | "title": "Moby-Dick",
171 | "author": "Herman Melville",
172 | "tags": "story,classic",
173 | "publisher": "Ebook Publisher.com",
174 | "identifier": "urn:isbn:978031600000X",
175 | "language": "en",
176 | "modified": "2015-09-29T17:00:00Z"
177 | },
178 | "links":[
179 | {
180 | "rel": "self",
181 | "href": "http://example.org/manifest.json",
182 | "type": "application/webpub+json"
183 | }
184 | ],
185 | "images":[
186 | {
187 | "href": "http://example.org/cover.jpg",
188 | "type": "image/jpeg",
189 | "height": 1400,
190 | "width": 800
191 | },
192 | {
193 | "href": "http://example.org/cover.svg",
194 | "type": "image/svg+xml"
195 | }
196 | ]
197 | }
198 | ```
199 |
--------------------------------------------------------------------------------
/assets/js/containers/PublisherListItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ConfirmIconButton from './ConfirmIconButton'
3 | import IconButton from '../components/IconButton'
4 | import Icon from '../components/Icon'
5 | import { removePublisher, setEditingPublisher, saveFile, verifyDomain } from '../actions'
6 | import './listitem.scss'
7 |
8 | class PublisherListItem extends React.Component {
9 | constructor () {
10 | super()
11 | this.state = {
12 | revealed: false
13 | }
14 | this.toggleReveal = this.toggleReveal.bind(this)
15 | this.getView = this.getView.bind(this)
16 | this.getEditing = this.getEditing.bind(this)
17 | this.cancelEvent = this.cancelEvent.bind(this)
18 | }
19 | toggleReveal (e) {
20 | e.stopPropagation()
21 | this.setState({
22 | revealed: !this.state.revealed
23 | })
24 | }
25 | cancelEvent (e, id) {
26 | e.stopPropagation()
27 | if (id === false) return
28 | this.props.dispatch(setEditingPublisher(id))
29 | }
30 | getView () {
31 | return (
32 |
33 |
34 | {`${this.props.item.name}${this.props.item.whitelisted ? '' : ' (awaiting approval)'}`}
35 | this.props.dispatch(removePublisher(this.props.item.id))} />
36 |
37 |
38 |
39 |
40 | AppID
41 |
42 |
43 |
44 |
Secret
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Publisher domain
54 |
55 |
56 |
57 |
Domain verification
58 |
59 |
60 | {this.props.item.verified && Ownership verified }
61 | {!this.props.item.verified && this.cancelEvent(e, this.props.item.id)}>Verify domain ownership }
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 | getEditing () {
70 | return (
71 | this.cancelEvent(e, false)}>
72 |
73 | {this.props.item.name}
74 | this.props.dispatch(removePublisher(this.props.item.id))} />
75 |
76 |
77 |
78 |
79 | Download {this.props.item.verification_key}.html and upload it to the root directory of your webserver. Then, click VERIFY to verify that you own and control {this.props.item.url} .
80 |
81 |
82 |
83 |
84 |
this.props.dispatch(saveFile(`${this.props.item.verification_key}.html`))}>Download file
85 |
86 |
87 | this.cancelEvent(e, null)}>Cancel
88 | this.props.dispatch(verifyDomain(this.props.item.id))}>Verify
89 |
90 |
91 |
92 |
93 | )
94 | }
95 | render () {
96 | return (
97 | this.props.editing ? this.getEditing() : this.getView()
98 | )
99 | }
100 | }
101 |
102 | export default PublisherListItem
103 |
--------------------------------------------------------------------------------
/assets/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import 'lib/default';
2 | @import 'shared/twopanels';
3 |
4 | .content {
5 | padding: 14px 0 42px 0;
6 | position: relative;
7 | overflow-y: auto;
8 |
9 | .error-box {
10 | min-height: 30px;
11 | line-height: 30px;
12 | background: $red;
13 | color: white;
14 | padding: 0 14px;
15 | margin: -14px 0 8px 0;
16 | }
17 | & > div {
18 | & > header {
19 | padding: 0 14px;
20 | }
21 | h1 {
22 | text-shadow: 1px 1px 2px $black-3;
23 | }
24 | h2 {
25 | margin: 0;
26 | padding: 0;
27 | font-weight: normal;
28 | font-size: 16px;
29 | margin-top: 4px;
30 | color: $text-dark-2;
31 | text-shadow: 1px 1px 2px $black-4;
32 | }
33 | .creator {
34 | padding: 0 14px;
35 | line-height: 60px;
36 |
37 | .btn {
38 | margin: 12px 0 12px 12px;
39 |
40 | @include break('small') {
41 | margin: 0 12px;
42 | }
43 | }
44 | }
45 | }
46 | .list {
47 | margin: 20px 14px;
48 | padding: 0;
49 | list-style: none;
50 | }
51 | .inputs,
52 | .details {
53 | padding: 20px 14px;
54 |
55 | .buttons {
56 | margin-top: 14px;
57 | text-align: right;
58 | }
59 |
60 | input[readonly] {
61 | background: transparent;
62 | border: none;
63 | outline: none;
64 | font-family: monospace;
65 | }
66 | .row {
67 | h4 {
68 | font-size: .8em;
69 | font-weight: normal;
70 | color: $black-2;
71 | }
72 | h3,
73 | h4 {
74 | margin: 10px 0;
75 | }
76 | }
77 | }
78 | &.working {
79 | & > .progress {
80 | top: 0;
81 | height: 4px;
82 | }
83 | }
84 | }
85 |
86 | .home {
87 | header {
88 | height: 50px;
89 | line-height: 50px;
90 | flex: none;
91 |
92 | .logo {
93 | color: $black-1;
94 | text-decoration: none;
95 | font-size: 1.2em;
96 | padding: 0 20px;
97 |
98 | @include break('small') {
99 | display: none;
100 | }
101 | }
102 | nav {
103 | a {
104 | text-decoration: none;
105 | display: inline-block;
106 | line-height: 50px;
107 | padding: 0 20px;
108 | color: $accent-2;
109 |
110 | @include break('small') {
111 | padding: 0 5px;
112 | }
113 | &:hover {
114 | text-decoration: underline;
115 | }
116 | &:last-of-type {
117 | line-height: 40px;
118 | height: 40px;
119 | margin: 5px 20px 5px 0;
120 | background: $accent-2;
121 | color: $text-light-1;
122 | border-radius: 3px;
123 |
124 | @include break('small') {
125 | margin: 5px 5px 5px 0;
126 | }
127 | }
128 | }
129 | }
130 | }
131 | footer {
132 | min-height: 40px;
133 | line-height: 40px;
134 | background: $accent-1;
135 | color: white;
136 | padding: 0 20px;
137 | box-shadow: $shadow-3;
138 | flex: none;
139 |
140 | a {
141 | color: $accent-3;
142 | text-decoration: none;
143 |
144 | &:hover {
145 | text-decoration: underline;
146 | }
147 | }
148 | }
149 | .paper {
150 | background: white;
151 | width: 85%;
152 | max-width: 900px;
153 | box-shadow: $shadow-1;
154 | padding: 100px 60px 140px 60px;
155 | margin: 60px auto 100px auto;
156 |
157 | h2,
158 | h3,
159 | h4 {
160 | margin: 40px 0 10px 0;
161 | font-weight: normal;
162 | color: $black-1;
163 | }
164 | hr {
165 | border: none;
166 | border-top: 1px solid $black-3;
167 | margin: 40px 80px;
168 | }
169 | a {
170 | color: $accent-2;
171 | }
172 | pre {
173 | background: $black-5;
174 | padding: 10px;
175 | border-radius: 3px;
176 | overflow-x: auto;
177 |
178 | &:before {
179 | display: block;
180 | content: '';
181 | background: $accent-3;
182 | height: 100%;
183 | width: 2px;
184 | top: 0;
185 | left: 0;
186 | }
187 | code {
188 | background: transparent;
189 | padding: 0;
190 | }
191 | }
192 | code {
193 | background: $black-5;
194 | padding: 2px 5px;
195 | border-radius: 3px;
196 | }
197 | p {
198 | line-height: 1.4em;
199 | }
200 |
201 | @include break('small') {
202 | width: 100%;
203 | padding: 30px 10px;
204 | margin: 0;
205 | }
206 | }
207 | ul.feed {
208 | list-style: none;
209 | margin: 0;
210 | padding: 0;
211 |
212 | li {
213 | min-height: 60px;
214 | border-bottom: 1px solid $black-5;
215 | padding: 20px 0;
216 |
217 | h3 {
218 | margin: 0;
219 |
220 | a {
221 | text-decoration: none;
222 | }
223 | }
224 |
225 | h4 {
226 | margin: 0;
227 | font-size: 1rem;
228 | color: $black-2;
229 | }
230 |
231 | .timestamp {
232 | color: $black-3;
233 | font-size: 1rem;
234 |
235 | @include break('small') {
236 | display: none;
237 | }
238 | }
239 |
240 | .tags {
241 | font-size: 0;
242 | margin-top: 6px;
243 |
244 | span {
245 | font-size: 0.9rem;
246 | border: 1px solid $black-5;
247 | border-radius: 3px;
248 | padding: 0 8px;
249 | line-height: 20px;
250 | color: $black-2;
251 | cursor: default;
252 |
253 | & + span {
254 | margin-left: 4px;
255 | }
256 | }
257 | }
258 |
259 | &:last-of-type {
260 | border: none;
261 | }
262 | }
263 | }
264 | }
265 |
--------------------------------------------------------------------------------