├── .babelrc ├── .ebextensions ├── 01_node_bin.config ├── 02_nodecommand.config └── 03_nginx_proxy.config ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .sailsrc ├── .travis.yml ├── CONTRIBUTING.md ├── Gruntfile.js ├── README.md ├── api ├── controllers │ ├── .gitkeep │ ├── AdminController.js │ ├── AuthController.js │ ├── BooksController.js │ ├── CatalogController.js │ ├── HomeController.js │ ├── PublishKeyController.js │ ├── TargetController.js │ └── UserController.js ├── errors │ └── HttpError.js ├── helpers │ ├── .gitkeep │ ├── docs.js │ ├── opds.js │ └── passport.js ├── hooks │ └── passport │ │ └── index.js ├── models │ ├── .gitkeep │ ├── Book.js │ ├── Passport.js │ ├── PublishKey.js │ ├── TargetUrl.js │ └── User.js ├── policies │ ├── .gitkeep │ ├── adminAuth.js │ ├── keyAuth.js │ └── sessionAuth.js └── util │ └── index.js ├── app.js ├── assets ├── .eslintrc ├── dependencies │ ├── .gitkeep │ └── sails.io.js ├── favicon.ico ├── images │ └── .gitkeep ├── js │ ├── .gitkeep │ ├── actions │ │ ├── admin.js │ │ ├── index.js │ │ └── login.js │ ├── admin.js │ ├── components │ │ ├── Icon.js │ │ ├── IconButton.js │ │ ├── Progress.js │ │ ├── UnderlineInput.js │ │ ├── icon.scss │ │ ├── iconbutton.scss │ │ └── underlineinput.scss │ ├── containers │ │ ├── Carousel.js │ │ ├── ConfirmIconButton.js │ │ ├── PublisherListItem.js │ │ ├── UriListItem.js │ │ ├── carousel.scss │ │ └── listitem.scss │ ├── index.js │ ├── lib │ │ ├── Ajax.js │ │ └── Util.js │ ├── login.js │ └── reducers │ │ ├── admin.js │ │ ├── index.js │ │ └── login.js ├── robots.txt ├── styles │ ├── admin.scss │ ├── index.scss │ ├── lib │ │ ├── default.scss │ │ └── vars.scss │ ├── login.scss │ └── shared │ │ ├── auth.scss │ │ └── twopanels.scss └── templates │ ├── .gitkeep │ ├── admin.html │ ├── index.html │ └── login.html ├── config ├── auth.js ├── blueprints.js ├── bootstrap.js ├── custom.js ├── datastores.js ├── env │ ├── development.js │ └── production.js ├── globals.js ├── http.js ├── i18n.js ├── locales │ ├── de.json │ ├── en.json │ ├── es.json │ └── fr.json ├── log.js ├── models.js ├── passport.js ├── policies.js ├── protocols.js ├── routes.js ├── security.js ├── session.js ├── sockets.js └── views.js ├── docs ├── api.md ├── integrations.md └── webhooks.md ├── ecosystem.config.js ├── install.md ├── knexfile.js ├── migrations ├── 20181119144327_create_initial_tables.js ├── 20181119152303_timestamps_to_bigint.js ├── 20181119183500_add_filters_to_targeturl.js ├── 20190220123908_AddPublisherToBooks.js ├── 20190220133443_add_srcHost_to_book.js ├── 20190224022422_add_admin_users.js ├── 20190226195925_publishkey_timestamps_bigint.js ├── 20190305170728_AddSigningSecretToUser.js ├── 20190314120819_store_opds_in_db.js ├── 20190314123654_remove_book_source.js └── 20190401161204_add_book_tags.js ├── package.json ├── tasks ├── config │ ├── babel.js │ ├── clean.js │ ├── coffee.js │ ├── concat.js │ ├── copy.js │ ├── cssmin.js │ ├── hash.js │ ├── jst.js │ ├── less.js │ ├── sails-linker.js │ ├── sync.js │ ├── uglify.js │ └── watch.js ├── pipeline.js └── register │ ├── build.js │ ├── buildProd.js │ ├── compileAssets.js │ ├── default.js │ ├── linkAssets.js │ ├── linkAssetsBuild.js │ ├── linkAssetsBuildProd.js │ ├── polyfill.js │ ├── prod.js │ └── syncAssets.js ├── test └── lifecycle.test.js ├── views ├── .eslintrc ├── 404.ejs ├── 500.ejs ├── layouts │ └── layout.ejs ├── pages │ ├── admin.ejs │ ├── app.ejs │ ├── index.ejs │ └── login.ejs └── shared │ ├── footer.html │ └── header.html └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": 2 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-proposal-object-rest-spread" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.ebextensions/01_node_bin.config: -------------------------------------------------------------------------------- 1 | container_commands: 2 | 01_node_binary: 3 | command: "ln -sf `ls -td /opt/elasticbeanstalk/node-install/node-* | head -1`/bin/node /bin/node" 4 | 02_npm_binary: 5 | command: "ln -sf `ls -td /opt/elasticbeanstalk/node-install/node-* | head -1`/bin/npm /bin/npm" 6 | -------------------------------------------------------------------------------- /.ebextensions/02_nodecommand.config: -------------------------------------------------------------------------------- 1 | # 02_nodecommand.config 2 | # option_settings: 3 | # aws:elasticbeanstalk:container:nodejs: 4 | # NodeCommand: npm run start:eb 5 | -------------------------------------------------------------------------------- /.ebextensions/03_nginx_proxy.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/tmp/45_nginx_https_rw.sh": 3 | owner: root 4 | group: root 5 | mode: "000644" 6 | content: | 7 | #! /bin/bash 8 | 9 | CONFIGURED=`grep -c "return 301 https" /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf` 10 | 11 | if [ $CONFIGURED = 0 ] 12 | then 13 | sed -i '/listen 8080;/a \ if ($http_x_forwarded_proto = "http") { return 301 https://$host$request_uri; }\n' /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf 14 | logger -t nginx_rw "https rewrite rules added" 15 | exit 0 16 | else 17 | logger -t nginx_rw "https rewrite rules already set" 18 | exit 0 19 | fi 20 | 21 | /opt/elasticbeanstalk/hooks/configdeploy/post/99_kill_default_nginx.sh: 22 | mode: "000755" 23 | owner: root 24 | group: root 25 | content: | 26 | #!/bin/bash -xe 27 | rm -f /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf 28 | status=`/sbin/status nginx` 29 | 30 | if [[ $status = *"start/running"* ]]; then 31 | echo "stopping nginx..." 32 | stop nginx 33 | echo "starting nginx..." 34 | start nginx 35 | else 36 | echo "nginx is not running... starting it..." 37 | start nginx 38 | fi 39 | 40 | container_commands: 41 | 00_appdeploy_rewrite_hook: 42 | command: cp -v /tmp/45_nginx_https_rw.sh /opt/elasticbeanstalk/hooks/appdeploy/enact 43 | 01_configdeploy_rewrite_hook: 44 | command: cp -v /tmp/45_nginx_https_rw.sh /opt/elasticbeanstalk/hooks/configdeploy/enact 45 | 02_rewrite_hook_perms: 46 | command: chmod 755 /opt/elasticbeanstalk/hooks/appdeploy/enact/45_nginx_https_rw.sh /opt/elasticbeanstalk/hooks/configdeploy/enact/45_nginx_https_rw.sh 47 | 03_rewrite_hook_ownership: 48 | command: chown root:users /opt/elasticbeanstalk/hooks/appdeploy/enact/45_nginx_https_rw.sh /opt/elasticbeanstalk/hooks/configdeploy/enact/45_nginx_https_rw.sh 49 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ################################################ 2 | # ╔═╗╔╦╗╦╔╦╗╔═╗╦═╗┌─┐┌─┐┌┐┌┌─┐┬┌─┐ 3 | # ║╣ ║║║ ║ ║ ║╠╦╝│ │ ││││├┤ ││ ┬ 4 | # o╚═╝═╩╝╩ ╩ ╚═╝╩╚═└─┘└─┘┘└┘└ ┴└─┘ 5 | # 6 | # > Formatting conventions for your Sails app. 7 | # 8 | # This file (`.editorconfig`) exists to help 9 | # maintain consistent formatting throughout the 10 | # files in your Sails app. 11 | # 12 | # For the sake of convention, the Sails team's 13 | # preferred settings are included here out of the 14 | # box. You can also change this file to fit your 15 | # team's preferences (for example, if all of the 16 | # developers on your team have a strong preference 17 | # for tabs over spaces), 18 | # 19 | # To review what each of these options mean, see: 20 | # http://editorconfig.org/ 21 | # 22 | ################################################ 23 | root = true 24 | 25 | [*] 26 | indent_style = space 27 | indent_size = 2 28 | end_of_line = lf 29 | charset = utf-8 30 | trim_trailing_whitespace = true 31 | insert_final_newline = true 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | assets/dependencies/**/*.js 2 | views/**/*.ejs 3 | 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ 3 | // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ 4 | // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ 5 | // A set of basic code conventions (similar to a .jshintrc file) designed to 6 | // encourage quality and consistency across your Sails app's code base. 7 | // These rules are checked against automatically any time you run `npm test`. 8 | // 9 | // > An additional eslintrc override file is included in the `assets/` folder 10 | // > right out of the box. This is specifically to allow for variations in acceptable 11 | // > global variables between front-end JavaScript code designed to run in the browser 12 | // > vs. backend code designed to run in a Node.js/Sails process. 13 | // 14 | // > Note: If you're using mocha, you'll want to add an extra override file to your 15 | // > `test/` folder so that eslint will tolerate mocha-specific globals like `before` 16 | // > and `describe`. 17 | // Designed for ESLint v4. 18 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 19 | // For more information about any of the rules below, check out the relevant 20 | // reference page on eslint.org. For example, to get details on "no-sequences", 21 | // you would visit `http://eslint.org/docs/rules/no-sequences`. If you're unsure 22 | // or could use some advice, come by https://sailsjs.com/support. 23 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 24 | 25 | "env": { 26 | "node": true 27 | }, 28 | 29 | "parserOptions": { 30 | "ecmaVersion": 8, 31 | "ecmaFeatures": { 32 | "experimentalObjectRestSpread": true 33 | } 34 | }, 35 | 36 | "globals": { 37 | // If "no-undef" is enabled below, be sure to list all global variables that 38 | // are used in this app's backend code (including the globalIds of models): 39 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 40 | "Promise": true, 41 | "sails": true, 42 | "_": true, 43 | "async": true 44 | // …and any others (e.g. `"Organization": true`) 45 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 46 | }, 47 | 48 | "rules": { 49 | "callback-return": ["error", ["done", "proceed", "next", "onwards", "callback", "cb"]], 50 | "camelcase": ["warn", {"properties":"always"}], 51 | "comma-style": ["warn", "last"], 52 | "curly": ["error"], 53 | "eqeqeq": ["error", "always"], 54 | "eol-last": ["warn"], 55 | "handle-callback-err": ["error"], 56 | "indent": ["warn", 2, { 57 | "SwitchCase": 1, 58 | "MemberExpression": "off", 59 | "FunctionDeclaration": {"body":1, "parameters":"off"}, 60 | "FunctionExpression": {"body":1, "parameters":"off"}, 61 | "CallExpression": {"arguments":"off"}, 62 | "ArrayExpression": 1, 63 | "ObjectExpression": 1, 64 | "ignoredNodes": ["ConditionalExpression"] 65 | }], 66 | "linebreak-style": ["error", "unix"], 67 | "no-dupe-keys": ["error"], 68 | "no-duplicate-case": ["error"], 69 | "no-extra-semi": ["warn"], 70 | "no-labels": ["error"], 71 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 72 | "no-redeclare": ["warn"], 73 | "no-return-assign": ["error", "always"], 74 | "no-sequences": ["error"], 75 | "no-trailing-spaces": ["warn"], 76 | "no-undef": ["off"], 77 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 78 | // ^^Note: If this "no-undef" rule is enabled (set to `["error"]`), then all model globals 79 | // (e.g. `"Organization": true`) should be included above under "globals". 80 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 81 | "no-unexpected-multiline": ["warn"], 82 | "no-unreachable": ["warn"], 83 | "no-unused-vars": ["warn", {"caughtErrors":"all", "caughtErrorsIgnorePattern": "^unused($|[A-Z].*$)", "argsIgnorePattern": "^unused($|[A-Z].*$)", "varsIgnorePattern": "^unused($|[A-Z].*$)" }], 84 | "no-use-before-define": ["error", {"functions":false}], 85 | "one-var": ["warn", "never"], 86 | "prefer-arrow-callback": ["warn", {"allowNamedFunctions":true}], 87 | "quotes": ["warn", "single", {"avoidEscape":false, "allowTemplateLiterals":true}], 88 | "semi": ["error", "always"], 89 | "semi-spacing": ["warn", {"before":false, "after":true}], 90 | "semi-style": ["warn", "last"] 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/local.js 2 | node_modules 3 | .tmp 4 | 5 | *~ 6 | *# 7 | .DS_STORE 8 | .netbeans 9 | nbproject 10 | .idea 11 | .node_history 12 | dump.rdb 13 | 14 | npm-debug.log.* 15 | lib-cov 16 | *.seed 17 | *.log 18 | *.out 19 | *.pid 20 | package-lock.json 21 | 22 | # Elastic Beanstalk Files 23 | .elasticbeanstalk/* 24 | !.elasticbeanstalk/*.cfg.yml 25 | !.elasticbeanstalk/*.global.yml 26 | .ebextensions/01_envvar.config 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Force npm to run node-gyp also as root, preventing permission denied errors in AWS with npm@5 2 | unsafe-perm=true -------------------------------------------------------------------------------- /.sailsrc: -------------------------------------------------------------------------------- 1 | { 2 | "generators": { 3 | "modules": { 4 | "permissions-api": "sails-permissions/generator" 5 | } 6 | }, 7 | "_generatedWith": { 8 | "sails": "1.0.2", 9 | "sails-generate": "1.15.28" 10 | }, 11 | "hooks": { 12 | "grunt": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | deploy: 5 | provider: elasticbeanstalk 6 | access_key_id: "Encrypted =" 7 | secret_access_key: 8 | secure: "Encypted =" 9 | region: "us-east-1" 10 | app: "example-app-name" 11 | env: "example-app-environment" 12 | bucket_name: "the-target-S3-bucket" 13 | 14 | #script: node testfile 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Adding features 2 | 3 | Checkout a new feature branch 4 | Make a pull request from your new branch to `staging` 5 | 6 | ## Code style 7 | 8 | Your code should comply with the standardjs styleguide 9 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gruntfile 3 | * 4 | * This Node script is executed when you run `grunt`-- and also when 5 | * you run `sails lift` (provided the grunt hook is installed and 6 | * hasn't been disabled). 7 | * 8 | * WARNING: 9 | * Unless you know what you're doing, you shouldn't change this file. 10 | * Check out the `tasks/` directory instead. 11 | * 12 | * For more information see: 13 | * https://sailsjs.com/anatomy/Gruntfile.js 14 | */ 15 | module.exports = function (grunt) { 16 | var loadGruntTasks = require('sails-hook-grunt/accessible/load-grunt-tasks') 17 | 18 | // Load Grunt task configurations (from `tasks/config/`) and Grunt 19 | // task registrations (from `tasks/register/`). 20 | loadGruntTasks(__dirname, grunt) 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # River of Ebooks 2 | https://github.com/EbookFoundation/river-of-ebooks 3 | 4 | ## About 5 | The River of Ebooks serves as an easy-to-use ebook aggregator. Publishers can send metadata from new and updated ebooks through the River where it will be available for any downstream consumers to read, allowing for a more widely available ebook collection. This way, ebooks can be made available on all end user sites, instead of only the site they were published with. 6 | 7 | ## Use cases 8 | 9 | ### Finding a list of relevant ebooks and keeping them updated 10 | The River provides a filterable feed of ebook metadata in OPDS2 format. Consumers can use this to find new ebooks to catalogue, and can then choose to receive updates to the metadata when new revisions of the ebook are published. 11 | 12 | 13 | 14 | ### Convenient notifications of ebook updates 15 | Consumers can find books without searching through the feed as well - just enter some filters and get notifications sent to a webhook whenever a book matching your filters is published. 16 | 17 | 18 | 19 | ### Propogate ebook revisions quickly across multiple websites 20 | Whenever a publisher sends ebook metadata through the River, it will be sent to any consumers who have chosen to be notified about changes to the book. This saves the publisher and consumers the trouble of having to worry about everyone having the latest version of the content. 21 | 22 | 23 | -------------------------------------------------------------------------------- /api/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/api/controllers/.gitkeep -------------------------------------------------------------------------------- /api/controllers/AdminController.js: -------------------------------------------------------------------------------- 1 | const HttpError = require('../errors/HttpError') 2 | 3 | module.exports = { 4 | show: async function (req, res) { 5 | res.view('pages/admin', { 6 | email: req.user.email 7 | }) 8 | }, 9 | listUsers: async function (req, res) { 10 | try { 11 | const users = await User.find({}) 12 | return res.json(users) 13 | } catch (e) { 14 | return (new HttpError(500, e.message)).send(res) 15 | } 16 | }, 17 | listPublishers: async function (req, res) { 18 | try { 19 | const publishers = await PublishKey.find({ 20 | select: ['id', 'user', 'appid', 'url', 'name', 'whitelisted', 'verified', 'verification_key', 'created_at', 'updated_at'] 21 | }).populate('user') 22 | return res.json(publishers) 23 | } catch (e) { 24 | return (new HttpError(500, e.message)).send(res) 25 | } 26 | }, 27 | editUser: async function (req, res) { 28 | try { 29 | const id = req.param('id') 30 | const patchData = req.param('patch') 31 | const updated = await User.updateOne({ id }).set({ 32 | ...patchData 33 | }) 34 | for (const key in updated) { 35 | if (patchData[key] === undefined && key !== 'id') delete updated[key] 36 | } 37 | return res.json(updated) 38 | } catch (e) { 39 | return (new HttpError(500, e.message)).send(res) 40 | } 41 | }, 42 | editPublisher: async function (req, res) { 43 | try { 44 | const id = req.param('id') 45 | const patchData = req.param('patch') 46 | const updated = await PublishKey.updateOne({ id }).set({ 47 | ...patchData 48 | }) 49 | for (const key in updated) { 50 | if (patchData[key] === undefined && key !== 'id') delete updated[key] 51 | } 52 | return res.json(updated) 53 | } catch (e) { 54 | return (new HttpError(500, e.message)).send(res) 55 | } 56 | }, 57 | deleteUser: async function (req, res) { 58 | try { 59 | const id = req.param('id') 60 | await User.destroyOne({ id }) 61 | return res.status(204).send() 62 | } catch (e) { 63 | return (new HttpError(500, e.message)).send(res) 64 | } 65 | }, 66 | deletePublisher: async function (req, res) { 67 | try { 68 | const id = req.param('id') 69 | await PublishKey.destroyOne({ id }) 70 | return res.status(204).send() 71 | } catch (e) { 72 | return (new HttpError(500, e.message)).send(res) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/controllers/BooksController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * BooksController 3 | * 4 | * @description :: Server-side actions for handling incoming requests. 5 | * @help :: See https://sailsjs.com/docs/concepts/actions 6 | */ 7 | 8 | const HttpError = require('../errors/HttpError') 9 | const { hmacSign } = require('../util') 10 | const request = require('request') 11 | const uriRegex = /^(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i 12 | 13 | module.exports = { 14 | publish: async function (req, res) { 15 | try { 16 | const body = req.body 17 | const host = req.hostname 18 | let result 19 | 20 | if (!host) throw new HttpError(400, 'Missing hostname') 21 | if (!body) throw new HttpError(400, 'Missing body') 22 | if (!body.metadata) throw new HttpError(400, 'Missing OPDS metadata') 23 | if (!body.metadata['@type'] || body.metadata['@type'] !== 'http://schema.org/Book') throw new HttpError(400, 'Invalid \'@type\': expected \'http://schema.org/Book\'') 24 | 25 | let tags = (body.metadata.tags || '').split(/,\s*/).filter(x => x.length) 26 | if (!tags.length && body.metadata.title) tags = body.metadata.title.replace(/[^\w\s]/g, '').split(/\s+/).filter(x => x.length >= 3) 27 | const query = { 28 | hostname: host, 29 | title: body.metadata.title, 30 | author: body.metadata.author, 31 | publisher: body.metadata.publisher, 32 | identifier: body.metadata.identifier, 33 | tags: JSON.stringify(tags || []), 34 | version: body.metadata.modified.replace(/\D/g, '') 35 | } 36 | 37 | const bookExists = await Book.findOne(query) 38 | 39 | if (bookExists) { 40 | throw new HttpError(400, 'Ebook already exists') 41 | } else { 42 | const { publisher, title, author, identifier } = body.metadata 43 | // require at least 3 fields to be filled out 44 | if ([title, identifier, author, publisher].reduce((a, x) => a + (x ? 1 : 0), 0) >= 3) { 45 | result = await Book.create({ 46 | ...query, 47 | opds: body 48 | }).fetch() 49 | } else { 50 | throw new HttpError(400, 'Please fill out at least 3 opds metadata fields (title, author, publisher, identifier)') 51 | } 52 | } 53 | 54 | sendUpdatesAsync(result) 55 | return res.json({ 56 | ...result, 57 | tags: JSON.parse(result.tags || '[]') 58 | }) 59 | } catch (e) { 60 | if (e instanceof HttpError) return e.send(res) 61 | return res.status(500).json({ 62 | error: e.message 63 | }) 64 | } 65 | } 66 | } 67 | 68 | async function sendUpdatesAsync (book) { 69 | const id = book.id 70 | const targets = await TargetUrl.find() 71 | if (!book) return 72 | for (const i in targets) { 73 | try { 74 | const item = targets[i] 75 | const user = await User.findOne({ id: item.user }) 76 | const { author: fAuthor, publisher: fPublisher, title: fTitle, identifier: fIsbn, tags: fTags, url } = item 77 | const { author: bAuthor, publisher: bPublisher, title: bTitle, identifier: bIsbn, tags: bTags, opds } = book 78 | 79 | if (uriRegex.test(url)) { 80 | if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue 81 | if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue 82 | if (fTitle && !((bTitle || '').includes(fTitle))) continue 83 | if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue 84 | 85 | const filterTags = JSON.parse(fTags || '[]') 86 | if (filterTags.length && filterTags[0].length) { 87 | const otherSet = new Set(filterTags) 88 | if (!([...new Set(JSON.parse(bTags || '[]'))].filter(x => otherSet.has(x)).length)) continue 89 | } 90 | sails.log('sending ' + book.id + ' info to ' + url) 91 | 92 | let content = opds 93 | const timestamp = Date.now() 94 | request.post({ 95 | url: url, 96 | headers: { 97 | 'User-Agent': 'RoE-aggregator', 98 | 'X-RoE-Signature': hmacSign(user.signing_secret, timestamp, JSON.stringify(content)), 99 | 'X-RoE-Request-Timestamp': timestamp 100 | }, 101 | body: content, 102 | json: true 103 | }, function (err, httpResp, body) { 104 | if (err) { 105 | sails.log(`error: failed to send book ${id} to ${url}`) 106 | } 107 | }) 108 | } 109 | } catch (e) { 110 | sails.log(`error: ${e.message}\n${e.stack}`) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /api/controllers/CatalogController.js: -------------------------------------------------------------------------------- 1 | const HttpError = require('../errors/HttpError') 2 | 3 | module.exports = { 4 | navigation: async function (req, res) { 5 | return res.json({ 6 | 'metadata': { 7 | 'title': 'RoE navigation' 8 | }, 9 | 'links': [ 10 | { 11 | 'rel': 'self', 12 | 'href': '/api/catalog', 13 | 'type': 'application/opds+json' 14 | } 15 | ], 16 | 'navigation': [ 17 | { 18 | 'href': 'new', 19 | 'title': 'New Publications', 20 | 'type': 'application/opds+json', 21 | 'rel': 'current' 22 | }, 23 | { 24 | 'href': 'all', 25 | 'title': 'All Publications', 26 | 'type': 'application/opds+json', 27 | 'rel': 'current' 28 | }, 29 | { 'rel': 'search', 'href': 'search{?title,author,isbn}', 'type': 'application/opds+json', 'templated': true } 30 | ] 31 | }) 32 | }, 33 | listNew: async function (req, res) { 34 | return res.status(400).json({ error: 'not implemented' }) 35 | }, 36 | listAll: async function (req, res) { 37 | try { 38 | const body = req.allParams() 39 | let page = 1 40 | const perPage = 200 41 | if (body.page) { 42 | page = Math.abs(+body.page) || 1 43 | delete body.page 44 | } 45 | const searchBody = { ...body } 46 | if (searchBody.tags) { 47 | const tags = searchBody.tags.split(/,\s*/) 48 | searchBody.tags = { 49 | or: [ 50 | ...tags.map(tag => ({ contains: tag })), 51 | { in: tags } 52 | ] 53 | } 54 | } 55 | let books = await Book.find(body ? searchBody : {}).sort('created_at DESC').skip((page * perPage) - perPage).limit(perPage) 56 | 57 | if (!books.length) { 58 | throw new HttpError(404, 'No books matching those parameters were found.') 59 | } 60 | 61 | books = books.map(b => b.opds) 62 | 63 | return res.json({ 64 | metadata: { 65 | title: 'RoE all publications', 66 | itemsPerPage: perPage, 67 | currentPage: page 68 | }, 69 | links: [ 70 | { rel: 'self', href: `all?page=${page}`, type: 'application/opds+json' }, 71 | { rel: 'prev', href: `all?page=${page > 1 ? page - 1 : page}`, type: 'application/opds+json' }, 72 | { rel: 'next', href: `all?page=${page + 1}`, type: 'application/opds+json' }, 73 | { rel: 'search', href: 'all{?title,author,version,isbn}', type: 'application/opds+json', templated: true } 74 | ], 75 | publications: books 76 | }) 77 | } catch (e) { 78 | if (e instanceof HttpError) return e.send(res) 79 | return res.status(500).json({ 80 | error: e.message 81 | }) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /api/controllers/HomeController.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | show: async function (req, res) { 3 | const docsHelper = await sails.helpers.docs() 4 | const content = await docsHelper.read('README', '../../') 5 | const feedItems = await Book.find({}).sort('created_at DESC').limit(20) 6 | res.view('pages/index', { 7 | content, 8 | feedItems 9 | }) 10 | }, 11 | docs: async function (req, res) { 12 | const docsHelper = await sails.helpers.docs() 13 | const page = req.param('page') 14 | if (!page || !(['api', 'integrations', 'webhooks'].includes(page))) { 15 | return res.notFound() 16 | } 17 | const content = await docsHelper.read(page) 18 | res.view('pages/index', { 19 | active: page, 20 | content 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/controllers/PublishKeyController.js: -------------------------------------------------------------------------------- 1 | const HttpError = require('../errors/HttpError') 2 | const request = require('request') 3 | const url = require('url') 4 | const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?/i 5 | 6 | module.exports = { 7 | create: async function (req, res) { 8 | try { 9 | const name = req.param('name') 10 | const url = req.param('url') 11 | if (!name.length) throw new Error('Name cannot be blank') 12 | if (!url.length) throw new Error('URL cannot be blank') 13 | if (!uriRegex.test(url)) throw new Error('Invalid URL') 14 | const created = await PublishKey.create({ 15 | user: req.user.id, 16 | name, 17 | url 18 | }).fetch() 19 | return res.json(created) 20 | } catch (e) { 21 | return (new HttpError(500, e.message)).send(res) 22 | } 23 | }, 24 | list: async function (req, res) { 25 | try { 26 | const keys = await PublishKey.find({ user: req.user.id }) 27 | return res.json(keys) 28 | } catch (e) { 29 | return (new HttpError(500, e.message)).send(res) 30 | } 31 | }, 32 | refresh: async function (req, res) { 33 | try { 34 | const id = req.param('id') 35 | const key = await PublishKey.update({ id, user: req.user.id }, {}).fetch() 36 | return res.json(key) 37 | } catch (e) { 38 | return (new HttpError(500, e.message)).send(res) 39 | } 40 | }, 41 | delete: async function (req, res) { 42 | try { 43 | const id = req.param('id') 44 | await PublishKey.destroyOne({ id }) 45 | return res.status(204).send() 46 | } catch (e) { 47 | return (new HttpError(500, e.message)).send(res) 48 | } 49 | }, 50 | verify: async function (req, res) { 51 | try { 52 | const id = req.param('id') 53 | const key = await PublishKey.findOne({ id }) 54 | if (!key) throw new HttpError(404, 'Cannot find that key') 55 | if (key.verified) throw new HttpError(400, 'That key\'s domain has already been verified') 56 | 57 | const verification = key.verification_key 58 | const _url = url.resolve(key.url, `${verification}.html`) 59 | 60 | const { httpResp, body } = await requestAsync({ 61 | url: _url, 62 | headers: { 'User-Agent': 'RoE-aggregator' } 63 | }) 64 | 65 | if (httpResp.statusCode !== 200 || body !== `${verification}.html`) throw new HttpError(404, `Could not find ${_url}`) 66 | 67 | const updated = await PublishKey.updateOne({ id }).set({ 68 | verified: true, 69 | verification_key: '' 70 | }) 71 | return res.json(updated) 72 | } catch (e) { 73 | if (e instanceof HttpError) return e.send(res) 74 | else return (new HttpError(500, e.message)).send(res) 75 | } 76 | } 77 | } 78 | 79 | function requestAsync (opts) { 80 | return new Promise((resolve, reject) => { 81 | request.get(opts, function (err, httpResp, body) { 82 | if (err) { 83 | reject(err) 84 | } else { 85 | resolve({ httpResp, body }) 86 | } 87 | }) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /api/controllers/TargetController.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const HttpError = require('../errors/HttpError') 3 | 4 | module.exports = { 5 | show: function (req, res) { 6 | res.view('pages/app', { 7 | email: req.user.email 8 | }) 9 | }, 10 | create: async function (req, res) { 11 | try { 12 | const url = await TargetUrl.create({ 13 | user: req.user.id 14 | }).fetch() 15 | return res.json(url) 16 | } catch (e) { 17 | return (new HttpError(500, e.message)).send(res) 18 | } 19 | }, 20 | edit: async function (req, res) { 21 | try { 22 | const id = req.param('id') 23 | const value = req.param('url') 24 | const author = req.param('author') || '' 25 | const publisher = req.param('publisher') || '' 26 | const title = req.param('title') || '' 27 | const isbn = req.param('isbn') || '' 28 | const tags = req.param('tags') || '' 29 | if (value.length) { 30 | const url = await TargetUrl.update({ id, user: req.user.id }, { 31 | url: value, 32 | author, 33 | publisher, 34 | title, 35 | isbn, 36 | tags: JSON.stringify(tags.split(/,\s*/)) 37 | }).fetch() 38 | return res.json(url) 39 | } else { 40 | return new HttpError(400, 'URL cannot be blank.').send(res) 41 | } 42 | } catch (e) { 43 | return (new HttpError(500, e.message)).send(res) 44 | } 45 | }, 46 | delete: async function (req, res) { 47 | try { 48 | const id = +req.param('id') 49 | await TargetUrl.destroyOne({ id }) 50 | return res.status(204).send() 51 | } catch (e) { 52 | return (new HttpError(500, e.message)).send(res) 53 | } 54 | }, 55 | list: async function (req, res) { 56 | try { 57 | let urls = await TargetUrl.find({ 58 | user: req.user.id 59 | }) 60 | urls = urls.map(url => ({ 61 | ...url, 62 | tags: JSON.parse(url.tags || '[]') 63 | })) 64 | return res.json(urls) 65 | } catch (e) { 66 | return (new HttpError(500, e.message)).send(res) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /api/controllers/UserController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UserController 3 | * 4 | * @description :: Server-side logic for managing Users 5 | * @help :: See http://links.sailsjs.org/docs/controllers 6 | */ 7 | const { generateToken } = require('../util') 8 | const HttpError = require('../errors/HttpError') 9 | 10 | module.exports = { 11 | /** 12 | * @override 13 | */ 14 | create: async function (req, res, next) { 15 | const passportHelper = await sails.helpers.passport() 16 | passportHelper.protocols.local.register(req.body, function (err, user) { 17 | if (err) { 18 | return res.status(500).json({ 19 | error: err.toString() 20 | }) 21 | } 22 | res.json(user) 23 | }) 24 | }, 25 | 26 | edit: async function (req, res, next) { 27 | const passportHelper = await sails.helpers.passport() 28 | passportHelper.protocols.local.update(req.body, function (err, user) { 29 | if (err) { 30 | return res.status(500).json({ 31 | error: err.toString() 32 | }) 33 | } 34 | res.json(user) 35 | }) 36 | }, 37 | 38 | me: function (req, res) { 39 | res.json(req.user) 40 | }, 41 | 42 | regenerateSigningSecret: async function (req, res) { 43 | try { 44 | const user = await User.updateOne({ id: req.user.id }, { signing_secret: await generateToken({ bytes: 24 }) }) 45 | return res.json(user) 46 | } catch (e) { 47 | return (new HttpError(500, e.message)).send(res) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/errors/HttpError.js: -------------------------------------------------------------------------------- 1 | class HttpError extends Error { 2 | constructor (status, message, hint) { 3 | super(message) 4 | if (typeof status !== 'number') throw new Error('HttpError status must be an integer') 5 | this.status = status 6 | this.hint = hint || 'none' 7 | } 8 | send (res) { 9 | return res.status(this.status).json({ 10 | error: this.message, 11 | hint: this.hint 12 | }) 13 | } 14 | } 15 | 16 | module.exports = HttpError 17 | -------------------------------------------------------------------------------- /api/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/api/helpers/.gitkeep -------------------------------------------------------------------------------- /api/helpers/docs.js: -------------------------------------------------------------------------------- 1 | const showdown = require('showdown') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | showdown.setFlavor('github') 6 | const converter = new showdown.Converter() 7 | 8 | module.exports = { 9 | friendlyName: 'Load DocsHelper', 10 | description: 'Load a DocsHelper instance', 11 | inputs: {}, 12 | exits: { 13 | success: { 14 | outputFriendlyName: 'Docs helper', 15 | outputDescription: 'A DocsHelper instance' 16 | } 17 | }, 18 | fn: async function (inputs, exits) { 19 | return exits.success(new DocsHelper()) 20 | } 21 | } 22 | 23 | class DocsHelper { 24 | read (file, relPath = '../../docs') { 25 | return new Promise((resolve, reject) => { 26 | fs.readFile(path.join(__dirname, relPath, `${file}.md`), { encoding: 'utf8' }, (err, data) => { 27 | if (err) reject(err) 28 | resolve(converter.makeHtml(data)) 29 | }) 30 | }) 31 | } 32 | convert (md) { 33 | return converter.makeHtml(md) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/helpers/opds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | friendlyName: 'Load OpdsHelper', 3 | description: 'Load a OpdsHelper instance', 4 | inputs: {}, 5 | exits: { 6 | success: { 7 | outputFriendlyName: 'Opds helper', 8 | outputDescription: 'A OpdsHelper instance' 9 | } 10 | }, 11 | fn: async function (inputs, exits) { 12 | return exits.success(new OpdsHelper()) 13 | } 14 | } 15 | 16 | function OpdsHelper () { 17 | this.book2opds = async function (book) { 18 | return new Promise((resolve, reject) => { 19 | const metadata = { 20 | '@type': 'http://schema.org/Book', 21 | modified: new Date(book.updated_at).toISOString() 22 | } 23 | if (book.title) metadata.title = book.title 24 | if (book.author) metadata.author = book.author 25 | if (book.isbn) metadata.identifier = `urn:isbn:${book.isbn}` 26 | resolve({ 27 | metadata, 28 | 'links': [ 29 | { 30 | 'rel': 'self', 31 | 'href': 'single/' + book.id, 32 | 'type': 'application/opds+json' 33 | } 34 | ], 35 | 'images': [] 36 | }) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/hooks/passport/index.js: -------------------------------------------------------------------------------- 1 | let passportHook = sails.hooks.passport 2 | 3 | if (!passportHook) { 4 | passportHook = function (sails) { 5 | return { 6 | initialize: async function (cb) { 7 | const helper = await sails.helpers.passport() 8 | helper.loadStrategies() 9 | return cb() 10 | } 11 | } 12 | } 13 | } 14 | 15 | module.exports = passportHook 16 | -------------------------------------------------------------------------------- /api/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/api/models/.gitkeep -------------------------------------------------------------------------------- /api/models/Book.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Book.js 3 | * 4 | * @description :: A model definition. Represents a database table/collection/etc. 5 | * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models 6 | */ 7 | 8 | module.exports = { 9 | 10 | attributes: { 11 | 12 | // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ 13 | // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ 14 | // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ 15 | id: { 16 | type: 'number', 17 | unique: true, 18 | autoIncrement: true 19 | }, 20 | title: { type: 'string', required: true }, 21 | author: { type: 'string' }, 22 | publisher: { type: 'string' }, 23 | identifier: { type: 'string' }, 24 | version: { type: 'string' }, 25 | hostname: { type: 'string' }, 26 | opds: { type: 'json' }, 27 | tags: { type: 'string' } 28 | 29 | // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ 30 | // ║╣ ║║║╠╩╗║╣ ║║╚═╗ 31 | // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ 32 | 33 | // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ 34 | // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ 35 | // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ 36 | 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /api/models/Passport.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt') 2 | 3 | async function hashPassword (passport) { 4 | try { 5 | var config = sails.config.auth.bcrypt 6 | var salt = config.rounds 7 | if (passport.password) { 8 | const hash = await bcrypt.hash(passport.password, salt) 9 | passport.password = hash 10 | } 11 | return passport 12 | } catch (e) { 13 | delete passport.password 14 | sails.log.error(e) 15 | throw e 16 | } 17 | } 18 | 19 | /** 20 | * Passport.js 21 | * 22 | * @description :: A model definition. Represents a database table/collection/etc. 23 | * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models 24 | */ 25 | 26 | module.exports = { 27 | attributes: { 28 | id: { 29 | type: 'number', 30 | unique: true, 31 | autoIncrement: true 32 | }, 33 | // local, oauth2, etc 34 | protocol: { 35 | type: 'string', 36 | required: true 37 | }, 38 | password: 'string', 39 | accesstoken: 'string', 40 | provider: 'string', 41 | identifier: 'string', 42 | tokens: 'json', 43 | 44 | // User association 45 | user: { 46 | model: 'User', 47 | required: true 48 | } 49 | }, 50 | 51 | /** 52 | * callback run before creating a Passport 53 | */ 54 | beforeCreate: async function (passport, next) { 55 | await hashPassword(passport) 56 | return next() 57 | }, 58 | 59 | /** 60 | * callback run before updating 61 | */ 62 | beforeUpdate: async function (passport, next) { 63 | await hashPassword(passport) 64 | return next() 65 | }, 66 | 67 | // methods 68 | validatePassword: async function (password, passport) { 69 | return bcrypt.compare(password, passport.password) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/models/PublishKey.js: -------------------------------------------------------------------------------- 1 | const { generateToken } = require('../util') 2 | 3 | module.exports = { 4 | attributes: { 5 | id: { 6 | type: 'number', 7 | unique: true, 8 | autoIncrement: true 9 | }, 10 | user: { 11 | model: 'User', 12 | required: true 13 | }, 14 | name: { 15 | type: 'string', 16 | required: true 17 | }, 18 | url: 'string', 19 | whitelisted: 'boolean', 20 | verified: 'boolean', 21 | verification_key: 'string', 22 | appid: { 23 | type: 'string' 24 | }, 25 | secret: { 26 | type: 'string' 27 | } 28 | }, 29 | beforeCreate: async function (key, next) { 30 | key.appid = await generateToken({ bytes: 12 }) 31 | key.secret = await generateToken({ bytes: 48 }) 32 | key.verification_key = await generateToken({ bytes: 24, base: 'hex' }) 33 | next() 34 | }, 35 | beforeUpdate: async function (key, next) { 36 | key.secret = await generateToken({ bytes: 48 }) 37 | next() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/models/TargetUrl.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | attributes: { 3 | id: { 4 | type: 'number', 5 | unique: true, 6 | autoIncrement: true 7 | }, 8 | user: { 9 | model: 'User', 10 | required: true 11 | }, 12 | url: { 13 | type: 'string' 14 | }, 15 | author: 'string', 16 | publisher: 'string', 17 | title: 'string', 18 | isbn: 'string', 19 | tags: 'string' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/models/User.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User.js 3 | * 4 | * @description :: A model definition. Represents a database table/collection/etc. 5 | * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models 6 | */ 7 | 8 | module.exports = { 9 | 10 | attributes: { 11 | // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ 12 | // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ 13 | // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ 14 | id: { 15 | type: 'number', 16 | unique: true, 17 | autoIncrement: true 18 | }, 19 | email: { 20 | type: 'string' 21 | }, 22 | admin: 'boolean', 23 | signing_secret: 'string' 24 | 25 | // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ 26 | // ║╣ ║║║╠╩╗║╣ ║║╚═╗ 27 | // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ 28 | 29 | // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ 30 | // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ 31 | // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ 32 | 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /api/policies/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/api/policies/.gitkeep -------------------------------------------------------------------------------- /api/policies/adminAuth.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (req, res, next) { 2 | if (process.env.NODE_ENV === 'development') return next() 3 | if (req.user && (req.user.id === 1 || req.user.admin)) next() 4 | else res.status(403).json({ error: 'You are not permitted to perform this action.' }) 5 | } 6 | -------------------------------------------------------------------------------- /api/policies/keyAuth.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (req, res, next) { 2 | const key = req.param('key') || req.headers['roe-key'] 3 | const secret = req.param('secret') || req.headers['roe-secret'] 4 | 5 | if (!key || !secret) return res.status(403).json({ error: 'Missing appid and secret.' }) 6 | 7 | const pk = await PublishKey.findOne({ appid: key, secret }) 8 | if (pk) { 9 | if (pk.whitelisted) return next() 10 | else res.status(403).json({ error: 'Your app has not been whitelisted yet. Please contact the site operator.' }) 11 | } 12 | 13 | res.status(403).json({ error: 'Invalid publishing key/secret pair.' }) 14 | } 15 | -------------------------------------------------------------------------------- /api/policies/sessionAuth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * sessionAuth 3 | * 4 | * @module :: Policy 5 | * @description :: Simple policy to allow any authenticated user 6 | * @docs :: http://sailsjs.org/#!documentation/policies 7 | */ 8 | module.exports = function (req, res, next) { 9 | if (process.env.NODE_ENV === 'development') return next() 10 | if (req.session.authenticated) return next() 11 | // res.status(403).json({ error: 'You are not permitted to perform this action.' }) 12 | res.redirect('/login') 13 | } 14 | -------------------------------------------------------------------------------- /api/util/index.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const APP_VERSION = 'v0' 3 | 4 | function asyncRead (adapter, helper, storage) { 5 | return new Promise((resolve, reject) => { 6 | adapter.read(storage, (err, data) => { 7 | if (err) return reject(err) 8 | try { 9 | data = data.toString('utf-8') 10 | let result 11 | if ((result = helper.deserializeOpds1(data)) !== false) { 12 | resolve(result) 13 | } else { 14 | resolve(JSON.parse(data)) 15 | } 16 | } catch (e) { 17 | reject(e) 18 | } 19 | }) 20 | }) 21 | } 22 | 23 | function generateToken ({ bytes, base }) { 24 | return new Promise((resolve, reject) => { 25 | crypto.randomBytes(bytes, (err, buf) => { 26 | if (err) reject(err) 27 | else resolve(buf.toString(base || 'base64')) 28 | }) 29 | }) 30 | } 31 | 32 | function hmacSign (secret, timestamp, body) { 33 | const value = `${APP_VERSION}:${timestamp}:${body}` 34 | const hmac = crypto.createHmac('sha256', secret.toString()).update(value, 'utf-8').digest('hex') 35 | return `${APP_VERSION}=${hmac}` 36 | } 37 | 38 | module.exports = { 39 | asyncRead, 40 | generateToken, 41 | hmacSign 42 | } 43 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * app.js 3 | * 4 | * Use `app.js` to run your app without `sails lift`. 5 | * To start the server, run: `node app.js`. 6 | * 7 | * This is handy in situations where the sails CLI is not relevant or useful, 8 | * such as when you deploy to a server, or a PaaS like Heroku. 9 | * 10 | * For example: 11 | * => `node app.js` 12 | * => `npm start` 13 | * => `forever start app.js` 14 | * => `node debug app.js` 15 | * 16 | * The same command-line arguments and env vars are supported, e.g.: 17 | * `NODE_ENV=production node app.js --port=80 --verbose` 18 | * 19 | * For more information see: 20 | * https://sailsjs.com/anatomy/app.js 21 | */ 22 | 23 | // Ensure we're in the project directory, so cwd-relative paths work as expected 24 | // no matter where we actually lift from. 25 | // > Note: This is not required in order to lift, but it is a convenient default. 26 | process.chdir(__dirname) 27 | 28 | // Attempt to import `sails` dependency, as well as `rc` (for loading `.sailsrc` files). 29 | var sails 30 | var rc 31 | try { 32 | sails = require('sails') 33 | rc = require('sails/accessible/rc') 34 | } catch (err) { 35 | console.error('Encountered an error when attempting to require(\'sails\'):') 36 | console.error(err.stack) 37 | console.error('--') 38 | console.error('To run an app using `node app.js`, you need to have Sails installed') 39 | console.error('locally (`./node_modules/sails`). To do that, just make sure you\'re') 40 | console.error('in the same directory as your app and run `npm install`.') 41 | console.error() 42 | console.error('If Sails is installed globally (i.e. `npm install -g sails`) you can') 43 | console.error('also run this app with `sails lift`. Running with `sails lift` will') 44 | console.error('not run this file (`app.js`), but it will do exactly the same thing.') 45 | console.error('(It even uses your app directory\'s local Sails install, if possible.)') 46 | }// -• 47 | 48 | // Start server 49 | sails.lift(rc('sails')) 50 | -------------------------------------------------------------------------------- /assets/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐ 3 | // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤ 4 | // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘ 5 | // ┌─ ┌─┐┌─┐┬─┐ ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ ┬┌─┐ ┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐ ─┐ 6 | // │ ├┤ │ │├┬┘ ├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ │└─┐ ├─┤└─┐└─┐├┤ │ └─┐ │ 7 | // └─ └ └─┘┴└─ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ └┘└─┘ ┴ ┴└─┘└─┘└─┘ ┴ └─┘ ─┘ 8 | // > An .eslintrc configuration override for use in the `assets/` directory. 9 | // 10 | // This extends the top-level .eslintrc file, primarily to change the set of 11 | // supported globals, as well as any other relevant settings. (Since JavaScript 12 | // code in the `assets/` folder is intended for the browser habitat, a different 13 | // set of globals is supported. For example, instead of Node.js/Sails globals 14 | // like `sails` and `process`, you have access to browser globals like `window`.) 15 | // 16 | // (See .eslintrc in the root directory of this Sails app for more context.) 17 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 18 | 19 | "extends": [ 20 | "../.eslintrc" 21 | ], 22 | 23 | "env": { 24 | "browser": true, 25 | "node": false 26 | }, 27 | 28 | "parserOptions": { 29 | "ecmaVersion": 8 30 | //^ If you are not using a transpiler like Babel, change this to `5`. 31 | }, 32 | 33 | "globals": { 34 | 35 | // Allow any window globals you're relying on here; e.g. 36 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 37 | "SAILS_LOCALS": true, 38 | "io": true, 39 | // "moment": true, 40 | // ...etc. 41 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 42 | 43 | // Make sure backend globals aren't indadvertently tolerated in our client-side JS: 44 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 45 | "sails": false, 46 | "_": false, 47 | "async": false 48 | // ...and any other backend globals (e.g. `"Organization": false`) 49 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 50 | }, 51 | 52 | "rules": { 53 | "no-undef": ["error"] 54 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 55 | // ^^Note: If you've enabled the "no-undef" rule in the top level .eslintrc file, then 56 | // the globalIds of Sails models should also be blacklisted above under "globals". 57 | // (In this scenario, also note that this override for the "no-undef" rule could 58 | // simply be removed, since it'd be redundant.) 59 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /assets/dependencies/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/assets/dependencies/.gitkeep -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/assets/favicon.ico -------------------------------------------------------------------------------- /assets/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/assets/images/.gitkeep -------------------------------------------------------------------------------- /assets/js/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/assets/js/.gitkeep -------------------------------------------------------------------------------- /assets/js/actions/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Ajax from '../lib/Ajax' 4 | 5 | const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str 6 | 7 | const ACTIONS = { 8 | set_working: 'set_working', 9 | error: 'error', 10 | set_admin_data: 'set_admin_data', 11 | a_update_user: 'a_update_user', 12 | a_update_publisher: 'a_update_publisher' 13 | } 14 | 15 | export default ACTIONS 16 | 17 | export const setWorking = working => ({ 18 | type: ACTIONS.set_working, 19 | data: working 20 | }) 21 | 22 | export const fetchAdminData = () => async (dispatch, getState) => { 23 | dispatch(setWorking(true)) 24 | try { 25 | const { data: user } = await Ajax.get({ 26 | url: getPath('/api/me') 27 | }) 28 | 29 | const { data: users } = await Ajax.get({ 30 | url: getPath('/admin/api/users') 31 | }) 32 | 33 | const { data: publishers } = await Ajax.get({ 34 | url: getPath('/admin/api/publishers') 35 | }) 36 | 37 | dispatch({ 38 | type: ACTIONS.set_admin_data, 39 | data: { 40 | user, 41 | users, 42 | publishers 43 | } 44 | }) 45 | } catch (e) { 46 | dispatch({ 47 | type: ACTIONS.error, 48 | data: e 49 | }) 50 | } finally { 51 | dispatch(setWorking(false)) 52 | } 53 | } 54 | 55 | export const patchUser = ({ id, ...data }) => async (dispatch, getState) => { 56 | dispatch(setWorking(true)) 57 | try { 58 | const { data: user } = await Ajax.patch({ 59 | url: getPath('/admin/api/users/' + id), 60 | data: { 61 | patch: data 62 | }, 63 | noProcess: true 64 | }) 65 | dispatch({ 66 | type: ACTIONS.a_update_user, 67 | data: user 68 | }) 69 | } catch (e) { 70 | dispatch({ 71 | type: ACTIONS.error, 72 | data: e 73 | }) 74 | } finally { 75 | dispatch(setWorking(false)) 76 | } 77 | } 78 | 79 | export const patchPublisher = ({ id, ...data }) => async (dispatch, getState) => { 80 | dispatch(setWorking(true)) 81 | try { 82 | const { data: publisher } = await Ajax.patch({ 83 | url: getPath('/admin/api/publishers/' + id), 84 | data: { 85 | patch: data 86 | }, 87 | noProcess: true 88 | }) 89 | dispatch({ 90 | type: ACTIONS.a_update_publisher, 91 | data: publisher 92 | }) 93 | } catch (e) { 94 | dispatch({ 95 | type: ACTIONS.error, 96 | data: e 97 | }) 98 | } finally { 99 | dispatch(setWorking(false)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/js/actions/login.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Ajax from '../lib/Ajax' 4 | 5 | const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str 6 | 7 | const ACTIONS = { 8 | set_working: 'set_working', 9 | set_user: 'set_user', 10 | set_password: 'set_password', 11 | set_carousel: 'set_carousel', 12 | set_error: 'set_error', 13 | clear_error: 'clear_error' 14 | } 15 | 16 | export default ACTIONS 17 | 18 | export const setWorking = working => ({ 19 | type: ACTIONS.set_working, 20 | data: working 21 | }) 22 | 23 | export const setEmail = email => ({ 24 | type: ACTIONS.set_user, 25 | data: email 26 | }) 27 | 28 | export const setPassword = pass => ({ 29 | type: ACTIONS.set_password, 30 | data: pass 31 | }) 32 | 33 | export const setCarousel = pos => (dispatch, getState) => { 34 | dispatch(clearError()) 35 | dispatch({ 36 | type: ACTIONS.set_carousel, 37 | data: pos 38 | }) 39 | } 40 | 41 | export const setError = data => ({ 42 | type: ACTIONS.set_error, 43 | data: data 44 | }) 45 | 46 | export const clearError = () => ({ 47 | type: ACTIONS.clear_error 48 | }) 49 | 50 | export const setLoggedIn = (data) => (dispatch, getState) => { 51 | window.localStorage.setItem('roe-token', JSON.stringify(data)) 52 | window.location.href = '/keys' 53 | } 54 | 55 | export const checkEmail = email => async (dispatch, getState) => { 56 | dispatch(setWorking(true)) 57 | dispatch(clearError()) 58 | if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { 59 | try { 60 | await Ajax.post({ 61 | url: getPath('/auth/email_exists'), 62 | data: { 63 | email 64 | } 65 | }) 66 | dispatch(setCarousel(2)) 67 | } catch (e) { 68 | dispatch(setError({ 69 | type: 'email', 70 | error: 'An account with that email does not exist.' 71 | })) 72 | } 73 | } else { 74 | dispatch(setError({ 75 | type: 'email', 76 | error: 'Please enter a valid email address.' 77 | })) 78 | } 79 | dispatch(setWorking(false)) 80 | } 81 | 82 | export const checkPassword = (email, password) => async (dispatch, getState) => { 83 | dispatch(setWorking(true)) 84 | 85 | // do email + password check 86 | try { 87 | const res = await Ajax.post({ 88 | url: getPath('/auth/local'), 89 | data: { 90 | identifier: email, 91 | password 92 | } 93 | }) 94 | dispatch(setLoggedIn(res)) 95 | // dispatch(setWorking(false)) 96 | } catch (e) { 97 | dispatch(setError({ 98 | type: 'password', 99 | error: e.message 100 | })) 101 | dispatch(setWorking(false)) 102 | } 103 | } 104 | 105 | export const signup = (email, password) => async (dispatch, getState) => { 106 | dispatch(setWorking(true)) 107 | dispatch(clearError()) 108 | if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { 109 | try { 110 | await Ajax.post({ 111 | url: getPath('/auth/email_available'), 112 | data: { 113 | email 114 | } 115 | }) 116 | await Ajax.post({ 117 | url: getPath('/register'), 118 | data: { 119 | email, 120 | password 121 | } 122 | }) 123 | dispatch(setCarousel(2)) 124 | } catch (e) { 125 | dispatch(setError({ 126 | type: 'email', 127 | error: e.message 128 | })) 129 | } 130 | } else { 131 | dispatch(setError({ 132 | type: 'email', 133 | error: 'Please enter a valid email address.' 134 | })) 135 | } 136 | dispatch(setWorking(false)) 137 | } 138 | -------------------------------------------------------------------------------- /assets/js/components/Icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './icon.scss' 3 | 4 | function getSVG (icon) { 5 | switch (icon) { 6 | case 'delete': return '' 7 | case 'add': return '' 8 | case 'pound': return '' 9 | case 'filter-variant': return '' 10 | case 'close': return '' 11 | case 'check': return '' 12 | case 'chevron-down': return '' 13 | case 'account-network': return '' 14 | case 'logout': return '' 15 | case 'square-edit-outline': return '' 16 | case 'eye': return '' 17 | case 'eye-close': return ' ' 18 | case 'shield-check': return '' 19 | case 'alert-circle': return '' 20 | case 'refresh': return '' 21 | case 'key': return '' 22 | case 'transfer-right': return '' 23 | case 'account': return '' 24 | case 'flash': return '' 25 | case 'menu': return '' 26 | case 'exit': return ' ' 27 | default: return icon || 'missing icon prop' 28 | } 29 | } 30 | 31 | function bsvg (icon) { 32 | return `${getSVG(icon)}` 33 | } 34 | 35 | const Icon = props => { 36 | return ( 37 | 38 | ) 39 | } 40 | 41 | export default Icon 42 | -------------------------------------------------------------------------------- /assets/js/components/IconButton.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import Icon from './Icon' 5 | import './iconbutton.scss' 6 | 7 | const IconButton = props => { 8 | return ( 9 | 12 | ) 13 | } 14 | 15 | export default IconButton 16 | -------------------------------------------------------------------------------- /assets/js/components/Progress.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | 5 | const Progress = props => ( 6 |
7 |
8 |
9 | ) 10 | 11 | export default Progress 12 | -------------------------------------------------------------------------------- /assets/js/components/UnderlineInput.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | 5 | import './underlineinput.scss' 6 | 7 | const UnderlineInput = props => ( 8 |
9 | 19 |
20 | 21 |
22 |
23 |
24 | ) 25 | 26 | export default UnderlineInput 27 | -------------------------------------------------------------------------------- /assets/js/components/icon.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/lib/vars'; 2 | 3 | .icon { 4 | display: inline-block; 5 | height: 40px; 6 | line-height: 40px; 7 | width: 40px; 8 | padding: 5px; 9 | background: transparent; 10 | border-radius: 50%; 11 | 12 | svg { 13 | height: 30px; 14 | width: 30px; 15 | 16 | path { 17 | fill: $white-2; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /assets/js/components/iconbutton.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/lib/vars'; 2 | 3 | .button.icon { 4 | height: 40px; 5 | line-height: 40px; 6 | width: 40px; 7 | // padding: 5px; 8 | text-align: center; 9 | background: transparent; 10 | border: none; 11 | outline: none; 12 | border-radius: 50%; 13 | transition: background 0.2s $transition, 14 | box-shadow 0.2s $transition; 15 | cursor: pointer; 16 | 17 | &:hover { 18 | background: $black-4; 19 | box-shadow: $shadow-1; 20 | } 21 | .icon { 22 | padding: 0; 23 | height: 30px; 24 | line-height: 30px; 25 | width: 30px; 26 | 27 | path { 28 | fill: $black-2; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /assets/js/components/underlineinput.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/lib/vars'; 2 | 3 | .underlined-input, 4 | .underlined-input-readonly { 5 | width: 100%; 6 | height: 46px; 7 | padding: 14px 0 0 0; 8 | margin: 0 0 8px 0; 9 | line-height: 26px; 10 | position: relative; 11 | 12 | $transition-time: .3s; 13 | 14 | input { 15 | position: absolute; 16 | background: transparent; 17 | border: none; 18 | outline: none; 19 | height: 26px; 20 | width: 100%; 21 | font-size: 1rem; 22 | bottom: 0; 23 | 24 | & + .reacts-to { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | width: 100%; 29 | height: 46px; 30 | pointer-events: none; 31 | overflow: hidden; 32 | 33 | label { 34 | position: absolute; 35 | top: 20px; 36 | font-size: 0.95rem; 37 | color: $black-2; 38 | pointer-events: none; 39 | transition: all $transition-time $transition; 40 | } 41 | .underline { 42 | position: absolute; 43 | bottom: 0; 44 | width: 100%; 45 | height: 1px; 46 | background: $black-4; 47 | 48 | &:before { 49 | content: ''; 50 | position: absolute; 51 | left: 50%; 52 | top: 0; 53 | width: 0; 54 | height: 2px; 55 | background: $accent-2; 56 | transition: left $transition-time $transition, 57 | width $transition-time $transition; 58 | } 59 | } 60 | } 61 | &:focus + .reacts-to, 62 | &:active + .reacts-to { 63 | .underline:before { 64 | width: 100%; 65 | left: 0; 66 | } 67 | } 68 | &:focus + .reacts-to, 69 | &:active + .reacts-to, 70 | &.has-content + .reacts-to { 71 | label { 72 | top: 0; 73 | font-size: 0.7rem; 74 | line-height: 14px; 75 | color: $accent-2; 76 | } 77 | } 78 | &.invalid { 79 | color: $red; 80 | } 81 | &.invalid:focus + .reacts-to, 82 | &.invalid:active + .reacts-to, 83 | &.invalid.has-content + .reacts-to { 84 | label { 85 | color: $red; 86 | } 87 | } 88 | &.invalid + .reacts-to { 89 | .underline:before { 90 | background: $red; 91 | } 92 | } 93 | } 94 | 95 | & + .underlined-input, 96 | & + .underlined-input-readonly { 97 | margin-top: 8px; 98 | } 99 | 100 | & + .underlined-input.stack-h, 101 | & + .underlined-input-readonly.stack-h { 102 | margin-left: 14px; 103 | margin-top: 0; 104 | 105 | @include break('small') { 106 | margin-left: 0; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /assets/js/containers/Carousel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import './carousel.scss' 5 | 6 | class Carousel extends React.Component { 7 | constructor () { 8 | super() 9 | this.getWidth = this.getWidth.bind(this) 10 | this.getOffset = this.getOffset.bind(this) 11 | } 12 | getWidth () { 13 | return this.props.children.length * 450 14 | } 15 | getOffset () { 16 | return -this.props.position * 450 17 | } 18 | render () { 19 | return ( 20 |
21 |
22 | {this.props.children} 23 |
24 |
25 | ) 26 | } 27 | } 28 | 29 | function handleClick (e, fn) { 30 | e.preventDefault() 31 | fn(e) 32 | } 33 | 34 | const CarouselItem = props => ( 35 |
handleClick(e, props.onButtonClick)}> 36 |
37 |

{props.header}

38 | {props.headerExtraContent} 39 |
40 | {props.inputs} 41 | {props.error} 42 | 48 | {props.footers && props.footers.length && 49 |
50 | { 51 | props.footers.map((x, i) => {x}) 52 | } 53 |
} 54 |
55 | ) 56 | 57 | export default Carousel 58 | export { 59 | CarouselItem 60 | } 61 | -------------------------------------------------------------------------------- /assets/js/containers/ConfirmIconButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconButton from '../components/IconButton' 3 | 4 | export default class ConfirmIconButton extends React.Component { 5 | constructor () { 6 | super() 7 | this.state = { 8 | confirmed: false 9 | } 10 | this.onClick = this.onClick.bind(this) 11 | this.timer = null 12 | } 13 | onClick (e) { 14 | e.stopPropagation() 15 | if (this.state.confirmed) { 16 | clearTimeout(this.timer) 17 | this.setState({ confirmed: false }) 18 | this.props.onClick(e) 19 | } else { 20 | this.setState({ confirmed: true }) 21 | this.timer = setTimeout(() => { 22 | this.setState({ confirmed: false }) 23 | }, 4000) 24 | } 25 | } 26 | render () { 27 | const { onClick, icon, ...rest } = this.props 28 | return ( 29 | 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 && } 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 | 85 |
    86 |
    87 | this.cancelEvent(e, null)}>Cancel 88 | 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/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 | -------------------------------------------------------------------------------- /assets/js/containers/carousel.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/lib/vars'; 2 | 3 | .carousel-container { 4 | position: relative; 5 | height: 100%; 6 | width: 100%; 7 | overflow-x: hidden; 8 | padding: 0 0 20px 0; 9 | opacity: 1; 10 | transition: opacity .2s $transition; 11 | 12 | .carousel { 13 | position: absolute; 14 | height: 100%; 15 | display: flex; 16 | transition: left 0.4s $transition; 17 | 18 | .carousel-item { 19 | // width: $auth-width; 20 | flex: 1; 21 | height: 100%; 22 | display: inline-block; 23 | // float: left; 24 | margin: 0 40px; 25 | position: relative; 26 | 27 | header { 28 | height: 128px; 29 | 30 | h1 { 31 | font-weight: normal; 32 | font-size: 1.5rem; 33 | margin: 0; 34 | } 35 | .account-banner { 36 | height: 50px; 37 | margin-top: 10px; 38 | line-height: 50px; 39 | display: flex; 40 | 41 | img { 42 | height: 50px; 43 | width: 50px; 44 | border-radius: 50%; 45 | vertical-align: middle; 46 | } 47 | .info-stack { 48 | flex: 1; 49 | padding: 0 10px; 50 | height: 100%; 51 | line-height: 16px; 52 | .name { 53 | display: inline-block; 54 | color: $text-dark-1; 55 | width: 100%; 56 | } 57 | .email { 58 | display: inline-block; 59 | color: $text-dark-2; 60 | width: 100%; 61 | } 62 | .hidden { 63 | display: none; 64 | } 65 | } 66 | a { 67 | color: $accent-2; 68 | text-decoration: none; 69 | } 70 | p { 71 | line-height: initial; 72 | } 73 | } 74 | } 75 | .carousel-error { 76 | color: $red; 77 | display: inline-block; 78 | font-size: 0.9rem; 79 | } 80 | footer { 81 | text-align: right; 82 | position: absolute; 83 | bottom: 20px; 84 | width: 100%; 85 | margin: 0; 86 | line-height: 30px; 87 | color: $text-dark-2; 88 | 89 | .btn { 90 | margin-left: 20px; 91 | } 92 | a { 93 | color: $accent-2; 94 | text-decoration: none; 95 | display: inline-block; 96 | width: 100%; 97 | } 98 | } 99 | .button-row { 100 | height: 50px; 101 | line-height: 50px; 102 | width: 100%; 103 | text-align: right; 104 | 105 | a { 106 | color: $accent-2; 107 | text-decoration: none; 108 | float: left; 109 | } 110 | } 111 | &.working { 112 | pointer-events: none; 113 | 114 | .input-carousel { 115 | opacity: 0.5; 116 | } 117 | .progress { 118 | height: 4px; 119 | } 120 | } 121 | @media screen and (max-width: 600px) { 122 | & { 123 | width: 100%; 124 | height: 100%; 125 | box-shadow: none; 126 | } 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /assets/js/containers/listitem.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/lib/vars'; 2 | 3 | .uri-list-item { 4 | position: relative; 5 | min-height: 60px; 6 | line-height: 60px; 7 | max-width: 100%; 8 | background: white; 9 | box-shadow: $shadow-0; 10 | padding: 0 0 0 14px; 11 | cursor: pointer; 12 | transition: margin 0.15s $transition, 13 | padding 0.15s $transition, 14 | border-radius 0.15s $transition; 15 | 16 | .button.icon { 17 | margin: 10px 5px 10px 5px; 18 | } 19 | 20 | &.editing { 21 | margin: 24px 8px; 22 | line-height: initial; 23 | padding: 10px 0 10px 14px; 24 | border-radius: 3px; 25 | cursor: default; 26 | 27 | header { 28 | h3 { 29 | display: inline-block; 30 | margin: 10px 0 0 0; 31 | font-weight: normal; 32 | cursor: pointer; 33 | } 34 | } 35 | .settings { 36 | padding: 0 14px 0 0; 37 | } 38 | h4 { 39 | margin: 30px 0 0 0; 40 | font-weight: normal; 41 | } 42 | } 43 | 44 | .stack { 45 | line-height: 25px; 46 | padding: 5px 0; 47 | 48 | > span { 49 | display: inline-block; 50 | width: 100%; 51 | @include ellip; 52 | } 53 | span.label { 54 | font-size: 0.75rem; 55 | color: $black-2; 56 | } 57 | span.value { 58 | margin-top: -4px; 59 | } 60 | } 61 | 62 | &.publisher-list-item { 63 | cursor: default; 64 | min-height: 120px; 65 | padding: 10px 0 10px 14px; 66 | 67 | .button.icon { 68 | margin: 0; 69 | 70 | &.tiny { 71 | padding: 0; 72 | height: 30px; 73 | width: 30px; 74 | } 75 | } 76 | .site-name { 77 | height: 40px; 78 | line-height: 40px; 79 | 80 | h3 { 81 | margin: 0; 82 | line-height: 40px; 83 | font-weight: normal; 84 | cursor: default; 85 | } 86 | .button.icon { 87 | margin-right: 10px; 88 | } 89 | } 90 | .stack { 91 | .label { 92 | display: inline-block; 93 | margin-bottom: -7px; 94 | } 95 | input { 96 | display: inline-block; 97 | line-height: 30px; 98 | background: transparent; 99 | } 100 | input.value { 101 | outline: none; 102 | border: none; 103 | font-family: monospace; 104 | margin-right: 10px; 105 | } 106 | } 107 | .col { 108 | margin-right: 10px; 109 | 110 | & + .col { 111 | margin-left: 10px; 112 | } 113 | } 114 | .verification { 115 | line-height: 40px; 116 | height: 40px; 117 | 118 | .icon { 119 | padding: 0; 120 | height: 30px; 121 | width: 30px; 122 | vertical-align: middle; 123 | margin-right: 10px; 124 | 125 | &.verified { 126 | path { 127 | fill: $green; 128 | } 129 | } 130 | &.unverified { 131 | path { 132 | fill: $red; 133 | } 134 | } 135 | } 136 | .btn { 137 | vertical-align: middle; 138 | border: 1px solid $black-4; 139 | line-height: 30px; 140 | height: 30px; 141 | padding: 0 10px; 142 | 143 | &:hover { 144 | box-shadow: none; 145 | color: $red; 146 | } 147 | } 148 | span { 149 | color: $green; 150 | } 151 | } 152 | &.editing { 153 | .buttons { 154 | margin-top: 10px; 155 | 156 | span.cancel { 157 | display: inline-block; 158 | line-height: 36px; 159 | text-align: right; 160 | padding-right: 10px; 161 | color: $black-2; 162 | cursor: pointer; 163 | } 164 | } 165 | p { 166 | .name { 167 | color: $accent-3; 168 | font-family: monospace; 169 | } 170 | } 171 | } 172 | &:not(.whitelisted) { 173 | &:before { 174 | position: absolute; 175 | content: ''; 176 | left: 0; 177 | top: 0; 178 | width: 4px; 179 | height: 100%; 180 | background: $red; 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /assets/js/lib/Util.js: -------------------------------------------------------------------------------- 1 | export default class Util { 2 | static combineReducers (...reducers) { 3 | return function (...reducerParams) { 4 | for (const reduce of reducers) { 5 | const changes = reduce(...reducerParams) 6 | if (changes && Object.keys(changes).length) return changes 7 | } 8 | return {} 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /assets/js/login.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import Progress from './components/Progress' 6 | import Carousel, { CarouselItem } from './containers/Carousel' 7 | import UnderlineInput from './components/UnderlineInput' 8 | import reducer from './reducers/login' 9 | import { setEmail, setPassword, setCarousel, checkEmail, checkPassword, signup } from './actions/login' 10 | 11 | import '../styles/login.scss' 12 | 13 | class App extends React.Component { 14 | constructor () { 15 | super() 16 | this.state = { 17 | carouselPosition: 1, 18 | emailError: '', 19 | passwordError: '', 20 | user: { 21 | email: '', 22 | password: '' 23 | }, 24 | working: false 25 | } 26 | 27 | this.dispatch = this.dispatch.bind(this) 28 | this.getEmailInputs = this.getEmailInputs.bind(this) 29 | this.getPasswordInputs = this.getPasswordInputs.bind(this) 30 | this.getPasswordHeader = this.getPasswordHeader.bind(this) 31 | } 32 | dispatch (action) { 33 | if (!action) throw new Error('dispatch: missing action') 34 | if (action instanceof Function) { 35 | action(this.dispatch, () => this.state) 36 | } else { 37 | const changes = reducer(this.state, action) 38 | if (!changes || !Object.keys(changes).length) return 39 | this.setState({ 40 | ...changes 41 | }) 42 | } 43 | } 44 | getEmailInputs () { 45 | return [ 46 | this.dispatch(setEmail(e.target.value))} 52 | value={this.state.user.email} /> 53 | ] 54 | } 55 | getPasswordInputs () { 56 | return [ 57 | this.dispatch(setPassword(e.target.value))} 63 | value={this.state.user.password} /> 64 | ] 65 | } 66 | getPasswordHeader () { 67 | return ( 68 |
    69 |
    70 | {this.state.user.email} 71 |
    72 | this.dispatch(setCarousel(1))}>Not you? 73 |
    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 | -------------------------------------------------------------------------------- /assets/js/reducers/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Actions from '../actions/admin' 4 | 5 | const reducer = (state = {}, action) => { 6 | const { type, data } = action 7 | let ind 8 | switch (type) { 9 | case Actions.set_working: 10 | return { 11 | working: data 12 | } 13 | case Actions.set_admin_data: 14 | return { 15 | user: data.user, 16 | users: data.users, 17 | publishers: data.publishers 18 | } 19 | case Actions.a_update_user: 20 | const modifiedUsers = [ ...state.users ] 21 | ind = modifiedUsers.findIndex(x => x.id === data.id) 22 | modifiedUsers[ind] = { ...modifiedUsers[ind], ...data } 23 | return { 24 | users: modifiedUsers 25 | } 26 | case Actions.a_update_publisher: 27 | const modifiedPublishers = [ ...state.publishers ] 28 | ind = modifiedPublishers.findIndex(x => x.id === data.id) 29 | modifiedPublishers[ind] = { ...modifiedPublishers[ind], ...data } 30 | return { 31 | publishers: modifiedPublishers 32 | } 33 | default: return {} 34 | } 35 | } 36 | 37 | export default reducer 38 | -------------------------------------------------------------------------------- /assets/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Actions from '../actions' 4 | 5 | const reducer = (state = {}, action) => { 6 | const { type, data } = action 7 | let urls, ind 8 | switch (type) { 9 | case Actions.set_working: 10 | return { 11 | working: data 12 | } 13 | case Actions.toggle_menu: 14 | return { 15 | navMenu: !state.navMenu 16 | } 17 | case Actions.set_user: 18 | return { 19 | user: { 20 | ...state.user, 21 | ...data 22 | } 23 | } 24 | case Actions.set_publishers: 25 | return { 26 | publishers: data 27 | } 28 | case Actions.list_url: 29 | return { 30 | urls: data || [] 31 | } 32 | case Actions.add_url: 33 | return { 34 | urls: state.urls.concat(data), 35 | error: '' 36 | } 37 | case Actions.delete_url: 38 | return { 39 | urls: state.urls.filter(x => x.id !== data), 40 | error: '' 41 | } 42 | case Actions.edit_url: 43 | urls = state.urls 44 | urls.find(x => x.id === data.id)[data.what] = data.value 45 | return { 46 | urls: urls 47 | } 48 | case Actions.set_editing_uri: 49 | return { 50 | editingUrl: data 51 | } 52 | case Actions.set_editing_publisher: 53 | return { 54 | editingPublisher: data 55 | } 56 | case Actions.error: 57 | return { 58 | error: (data || {}).data ? (data || {}).data : (data || {}).message 59 | } 60 | case Actions.add_publisher: 61 | return { 62 | publishers: state.publishers.concat(data), 63 | error: '' 64 | } 65 | case Actions.delete_publisher: 66 | return { 67 | publishers: state.publishers.filter(x => x.id !== data), 68 | error: '' 69 | } 70 | case Actions.update_publisher: 71 | const modifiedPublishers = [ ...state.publishers ] 72 | ind = modifiedPublishers.findIndex(x => x.id === data.id) 73 | modifiedPublishers[ind] = { ...modifiedPublishers[ind], ...data } 74 | return { 75 | publishers: modifiedPublishers, 76 | error: '' 77 | } 78 | default: return {} 79 | } 80 | } 81 | 82 | export default reducer 83 | -------------------------------------------------------------------------------- /assets/js/reducers/login.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import Actions from '../actions/login' 4 | 5 | const reducer = (state = {}, action) => { 6 | const { type, data } = action 7 | switch (type) { 8 | case Actions.set_user: 9 | return { 10 | user: { 11 | ...state.user, 12 | email: data 13 | } 14 | } 15 | case Actions.set_password: 16 | return { 17 | user: { 18 | ...state.user, 19 | password: data 20 | } 21 | } 22 | case Actions.set_carousel: 23 | return { 24 | carouselPosition: data 25 | } 26 | case Actions.set_working: 27 | return { 28 | working: data 29 | } 30 | case Actions.set_error: 31 | switch (data.type) { 32 | case 'email': 33 | return { 34 | emailError: data.error 35 | } 36 | case 'password': 37 | return { 38 | passwordError: data.error 39 | } 40 | default: return {} 41 | } 42 | case Actions.clear_error: 43 | return { 44 | emailError: '', 45 | passwordError: '' 46 | } 47 | } 48 | } 49 | 50 | export default reducer 51 | -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | # The robots.txt file is used to control how search engines index your live URLs. 2 | # See https://sailsjs.com/anatomy/assets/robots-txt for more information. 3 | 4 | 5 | 6 | # If you want to discourage search engines from indexing this site, uncomment 7 | # the "User-Agent" and "Disallow" settings on the next two lines: 8 | # User-Agent: * 9 | # Disallow: / 10 | -------------------------------------------------------------------------------- /assets/styles/admin.scss: -------------------------------------------------------------------------------- 1 | @import 'index'; 2 | 3 | .admin-container { 4 | .list { 5 | li { 6 | padding: 5px 0; 7 | min-height: 50px; 8 | line-height: 50px; 9 | padding: 0 14px; 10 | 11 | header { 12 | h3 { 13 | font-weight: normal; 14 | line-height: 30px; 15 | margin: 0; 16 | } 17 | } 18 | .stack { 19 | line-height: 20px; 20 | } 21 | .cb-label { 22 | display: inline-block; 23 | height: 40px; 24 | line-height: 40px; 25 | margin-right: 14px; 26 | cursor: pointer; 27 | user-select: none; 28 | } 29 | input.checkbox[type=checkbox] { 30 | display: none; 31 | 32 | + label { 33 | display: inline-block; 34 | position: relative; 35 | height: 20px; 36 | width: 20px; 37 | margin: 8px 0 12px 0; 38 | vertical-align: middle; 39 | border: 2px solid $black-1; 40 | cursor: pointer; 41 | } 42 | 43 | &:checked + label { 44 | &:before { 45 | position: absolute; 46 | content: ''; 47 | background: $accent-2; 48 | height: 14px; 49 | width: 14px; 50 | left: 1px; 51 | top: 1px; 52 | } 53 | } 54 | } 55 | .key-value { 56 | span { 57 | line-height: 30px; 58 | 59 | &.contains-icon { 60 | margin-bottom: 10px; 61 | } 62 | } 63 | .icon { 64 | padding: 0; 65 | height: 20px; 66 | width: 20px; 67 | vertical-align: middle; 68 | margin-left: 10px; 69 | svg { 70 | height: 20px; 71 | width: 20px; 72 | } 73 | &.verified { 74 | path { 75 | fill: $green; 76 | } 77 | } 78 | &.unverified { 79 | path { 80 | fill: $red; 81 | } 82 | } 83 | } 84 | } 85 | .key { 86 | margin-right: 10px; 87 | } 88 | .value { 89 | font-family: monospace; 90 | color: $black-3; 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/styles/lib/default.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | html, 4 | body, 5 | #root, 6 | .root-container { 7 | height: 100%; 8 | width: 100%; 9 | margin: 0; 10 | padding: 0; 11 | background: $background-1; 12 | font-family: sans-serif; 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | } 18 | 19 | h1 { 20 | margin: 0; 21 | padding: 0; 22 | font-weight: normal; 23 | } 24 | 25 | .flex-container { 26 | display: flex; 27 | 28 | &.flex-center { 29 | align-items: center; 30 | justify-content: center; 31 | flex-direction: column; 32 | } 33 | &.flex-horizintal { 34 | flex-direction: row; 35 | } 36 | &.flex-vertical { 37 | flex-direction: column; 38 | } 39 | .flex { 40 | flex: 1; 41 | } 42 | } 43 | 44 | .btn { 45 | position: relative; 46 | height: 36px; 47 | padding: 0 20px; 48 | background: $accent-2; 49 | color: white; 50 | border: none; 51 | border-radius: 3px; 52 | outline: none; 53 | font-size: 0.9rem; 54 | text-transform: uppercase; 55 | box-shadow: $shadow-1; 56 | transition: box-shadow .15s ease-in-out; 57 | cursor: pointer; 58 | user-select: none; 59 | 60 | &:focus{ 61 | outline: initial; 62 | } 63 | &:hover { 64 | box-shadow: $shadow-3; 65 | } 66 | &:active { 67 | box-shadow: $shadow-2; 68 | } 69 | &.btn-with-icon { 70 | padding: 0 20px 0 0; 71 | 72 | i { 73 | font-size: 1.5em; 74 | height: 30px; 75 | width: 30px; 76 | vertical-align: middle; 77 | margin: 0 10px; 78 | } 79 | } 80 | &.btn-social-signin { 81 | text-transform: none; 82 | 83 | &.btn-google { 84 | background: #dd4b39; 85 | } 86 | } 87 | &.btn-clear { 88 | background: transparent; 89 | color: $text-dark-1; 90 | box-shadow: none; 91 | border: 1px solid $accent-2; 92 | 93 | &:hover { 94 | box-shadow: $shadow-2; 95 | } 96 | } 97 | & + .btn { 98 | margin-left: 6px; 99 | } 100 | } 101 | a:hover { 102 | text-decoration: underline !important; 103 | } 104 | 105 | @-webkit-keyframes indeterminate {0% {left: -35%; right: 100%; } 60% {left: 100%; right: -90%; } 100% {left: 100%; right: -90%; } } @keyframes indeterminate {0% {left: -35%; right: 100%; } 60% {left: 100%; right: -90%; } 100% {left: 100%; right: -90%; } } @-webkit-keyframes indeterminate-short {0% {left: -200%; right: 100%; } 60% {left: 107%; right: -8%; } 100% {left: 107%; right: -8%; } } @keyframes indeterminate-short {0% {left: -200%; right: 100%; } 60% {left: 107%; right: -8%; } 100% {left: 107%; right: -8%; } } 106 | 107 | .progress { 108 | position: absolute; 109 | display: inline-block; 110 | top: 0; 111 | height: 0; 112 | width: 100%; 113 | background-color: $accent-3; 114 | background-clip: padding-box; 115 | margin: 0; 116 | overflow: hidden; 117 | transition: height .2s $transition; 118 | 119 | .indeterminate { 120 | background-color: $accent-2; 121 | 122 | &:before { 123 | content: ''; 124 | position: absolute; 125 | background-color: inherit; 126 | top: 0; 127 | left: 0; 128 | bottom: 0; 129 | will-change: left, right; 130 | -webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite; 131 | animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite; 132 | } 133 | &:after { 134 | content: ''; 135 | position: absolute; 136 | background-color: inherit; 137 | top: 0; 138 | left: 0; 139 | bottom: 0; 140 | will-change: left, right; 141 | -webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite; 142 | animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite; 143 | -webkit-animation-delay: 1.15s; 144 | animation-delay: 1.15s; 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /assets/styles/lib/vars.scss: -------------------------------------------------------------------------------- 1 | $shadow-0: 0 2px 2px 0 rgba(0,0,0,0.14), 0 3px 1px -2px rgba(0,0,0,0.12), 0 1px 5px 0 rgba(0,0,0,0.2); 2 | $shadow-1: 0 1.5px 4px rgba(0, 0, 0, 0.24), 0 1.5px 6px rgba(0, 0, 0, 0.12); 3 | $shadow-2: 0 0 4px rgba(0,0,0,.14),0 4px 8px rgba(0,0,0,.28); 4 | $shadow-3: 0 8px 17px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); 5 | $shadow-4: 0 10px 20px rgba(0, 0, 0, 0.22), 0 14px 56px rgba(0, 0, 0, 0.25); 6 | $shadow-5: 0 15px 24px rgba(0, 0, 0, 0.22), 0 19px 76px rgba(0, 0, 0, 0.3); 7 | 8 | $transition: cubic-bezier(0.23, 0.54, 0.19, 0.99); 9 | $transition-2: cubic-bezier(0.08, 0.54, 0.45, 0.91); 10 | 11 | $black-1: rgba(0,0,0,.87); 12 | $black-2: rgba(0,0,0,.54); 13 | $black-3: rgba(0,0,0,.38); 14 | $black-4: rgba(0,0,0,.12); 15 | $black-5: rgba(0,0,0,.07); 16 | 17 | $white-1: white; 18 | $white-2: rgba(255,255,255,.75); 19 | $white-3: rgba(255,255,255,.35); 20 | $white-4: rgba(255,255,255,.10); 21 | $white-5: rgba(255,255,255,.03); 22 | 23 | $auth-width: 450px; 24 | 25 | $background-1: #f2f2f2; 26 | $background-2: white; 27 | 28 | $text-dark-1: $black-1; 29 | $text-dark-2: $black-2; 30 | $text-light-1: $white-1; 31 | $text-light-2: $white-2; 32 | 33 | $accent-1: #102237; 34 | $accent-2: #18517c; 35 | $accent-3: #4f91b8; 36 | 37 | $red: #FE4C52; 38 | $green: #4FE070; 39 | 40 | $small-break: 640px; 41 | $medium-break: 800px; 42 | $large-break: 1200px; 43 | 44 | $breakpoints: ( 45 | 'small': ( max-width: $small-break ), 46 | 'medium': ( max-width: $medium-break ), 47 | 'large': ( max-width: $large-break ) 48 | ) !default; 49 | 50 | @mixin break($breakpoint) { 51 | @if map-has-key($breakpoints, $breakpoint) { 52 | @media #{inspect(map-get($breakpoints, $breakpoint))} { 53 | @content; 54 | } 55 | } 56 | @else { 57 | @warn "No value could be retrieved from `#{$breakpoint}`. " 58 | + "Available breakpoints are: #{map-keys($breakpoints)}."; 59 | } 60 | } 61 | 62 | @mixin ellip() { 63 | white-space: nowrap; 64 | overflow: hidden; 65 | text-overflow: ellipsis; 66 | } 67 | -------------------------------------------------------------------------------- /assets/styles/login.scss: -------------------------------------------------------------------------------- 1 | @import 'lib/default'; 2 | @import 'shared/auth'; 3 | -------------------------------------------------------------------------------- /assets/styles/shared/auth.scss: -------------------------------------------------------------------------------- 1 | @import '../lib/vars'; 2 | 3 | #root:after { 4 | content: ''; 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | width: 100%; 9 | height: 45%; 10 | background: $accent-1; 11 | box-shadow: $shadow-0; 12 | z-index: 0; 13 | } 14 | 15 | .window { 16 | position: relative; 17 | height: 500px; 18 | width: $auth-width; 19 | background: $background-2; 20 | box-shadow: $shadow-1; 21 | z-index: 1; 22 | padding: 40px 0; 23 | overflow: hidden; 24 | border-radius: 3px; 25 | 26 | &:before { 27 | opacity: 0; 28 | z-index: 2; 29 | position: absolute; 30 | content: ''; 31 | height: 100%; 32 | width: 100%; 33 | top: 4px; 34 | left: 0; 35 | background: rgba(255,255,255,.50); 36 | transition: .2s opacity $transition; 37 | pointer-events: none; 38 | } 39 | 40 | &.working { 41 | & > .progress { 42 | top: 0; 43 | height: 4px; 44 | } 45 | &:before { 46 | opacity: 1; 47 | pointer-events: initial; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /assets/styles/shared/twopanels.scss: -------------------------------------------------------------------------------- 1 | .two-panels { 2 | .nav-left { 3 | width: 300px; 4 | height: 100%; 5 | background: $accent-1; 6 | color: $text-light-1; 7 | box-shadow: $shadow-1; 8 | overflow-y: auto; 9 | z-index: 100; 10 | 11 | header { 12 | line-height: 50px; 13 | padding: 0 14px; 14 | 15 | h1 { 16 | height: 50px; 17 | 18 | .menu-small { 19 | display: none; 20 | margin: 5px 5px 5px 0; 21 | 22 | path { 23 | fill: $text-light-1; 24 | } 25 | 26 | @include break('medium') { 27 | display: inline-block; 28 | margin: 5px; 29 | } 30 | } 31 | span:not(.icon) { 32 | @include break('medium') { 33 | display: none; 34 | } 35 | } 36 | } 37 | h2 { 38 | margin: -10px 0 0 0; 39 | padding: 0; 40 | font-weight: normal; 41 | font-size: 12pt; 42 | height: 36px; 43 | line-height: 36px; 44 | color: $white-2; 45 | } 46 | a { 47 | text-decoration: none; 48 | color: $accent-3; 49 | } 50 | } 51 | ul { 52 | list-style: none; 53 | margin: 0; 54 | padding: 0; 55 | 56 | li { 57 | height: 50px; 58 | line-height: 50px; 59 | overflow: hidden; 60 | border-bottom: 1px solid $white-4; 61 | 62 | &:hover a { 63 | background: $white-5; 64 | } 65 | &:last-of-type { 66 | border-bottom: none 67 | } 68 | 69 | a { 70 | display: inline-block; 71 | height: 100%; 72 | width: 100%; 73 | padding: 0 12px; 74 | text-decoration: none !important; 75 | color: $white-2; 76 | 77 | &.active { 78 | background: $white-4; 79 | } 80 | .icon { 81 | margin: 5px 5px 5px 0; 82 | vertical-align: middle; 83 | 84 | @include break('medium') { 85 | margin: 5px; 86 | } 87 | } 88 | span:not(.icon) { 89 | vertical-align: middle; 90 | display: inline-block; 91 | line-height: 50px; 92 | 93 | @include break('medium') { 94 | display: none; 95 | } 96 | @include ellip(); 97 | } 98 | @include break('medium') { 99 | padding: 0; 100 | } 101 | } 102 | } 103 | } 104 | } 105 | .content { 106 | z-index: 1; 107 | 108 | .cols { 109 | @include break('small') { 110 | flex-direction: column; 111 | } 112 | } 113 | } 114 | @include break('medium') { 115 | .nav-left { 116 | position: absolute; 117 | transition: width .3s $transition; 118 | 119 | header { 120 | min-height: 76px; 121 | } 122 | } 123 | 124 | &:not(.nav-active) { 125 | .nav-left { 126 | width: 50px; 127 | 128 | header { 129 | padding: 0; 130 | 131 | h2 { 132 | display: none; 133 | } 134 | } 135 | } 136 | } 137 | &.nav-active { 138 | span:not(.icon) { 139 | overflow: hidden; 140 | display: inline-block !important; 141 | } 142 | .nav-left { 143 | header { 144 | padding: 0 14px 0 0; 145 | 146 | h2 { 147 | display: flex; 148 | margin-left: 50px; 149 | } 150 | } 151 | } 152 | } 153 | > .content { 154 | margin-left: 50px; 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /assets/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EbookFoundation/river-of-ebooks/f13fbc452f01e393179f355b119c147899273b40/assets/templates/.gitkeep -------------------------------------------------------------------------------- /assets/templates/admin.html: -------------------------------------------------------------------------------- 1 | <% var key, item %> 2 | <% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %> 3 | 4 | 5 | 6 | 7 | River of Ebooks | admin 8 | 9 | <% for (item of htmlWebpackPlugin.options.links) { 10 | if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %> 11 | <%= key %>="<%= item[key] %>"<% } %> /><% 12 | } %> 13 | 14 | 15 | 16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/templates/index.html: -------------------------------------------------------------------------------- 1 | <% var key, item %> 2 | <% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %> 3 | 4 | 5 | 6 | 7 | River of Ebooks 8 | 9 | <% for (item of htmlWebpackPlugin.options.links) { 10 | if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %> 11 | <%= key %>="<%= item[key] %>"<% } %> /><% 12 | } %> 13 | 14 | 15 | 16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/templates/login.html: -------------------------------------------------------------------------------- 1 | <% var key, item %> 2 | <% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %> 3 | 4 | 5 | 6 | 7 | River of Ebooks - Login 8 | 9 | <% for (item of htmlWebpackPlugin.options.links) { 10 | if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %> 11 | <%= key %>="<%= item[key] %>"<% } %> /><% 12 | } %> 13 | 14 | 15 | 16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /config/auth.js: -------------------------------------------------------------------------------- 1 | module.exports.auth = { 2 | bcrypt: { 3 | rounds: 8 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /config/blueprints.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Blueprint API Configuration 3 | * (sails.config.blueprints) 4 | * 5 | * For background on the blueprint API in Sails, check out: 6 | * https://sailsjs.com/docs/reference/blueprint-api 7 | * 8 | * For details and more available options, see: 9 | * https://sailsjs.com/config/blueprints 10 | */ 11 | 12 | module.exports.blueprints = { 13 | 14 | /*************************************************************************** 15 | * * 16 | * Automatically expose implicit routes for every action in your app? * 17 | * * 18 | ***************************************************************************/ 19 | 20 | // actions: false, 21 | 22 | /*************************************************************************** 23 | * * 24 | * Automatically expose RESTful routes for your models? * 25 | * * 26 | ***************************************************************************/ 27 | 28 | // rest: true, 29 | 30 | /*************************************************************************** 31 | * * 32 | * Automatically expose CRUD "shortcut" routes to GET requests? * 33 | * (These are enabled by default in development only.) * 34 | * * 35 | ***************************************************************************/ 36 | 37 | // shortcuts: true, 38 | 39 | } 40 | -------------------------------------------------------------------------------- /config/bootstrap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap 3 | * (sails.config.bootstrap) 4 | * 5 | * An asynchronous bootstrap function that runs just before your Sails app gets lifted. 6 | * > Need more flexibility? You can also do this by creating a hook. 7 | * 8 | * For more information on bootstrapping your app, check out: 9 | * https://sailsjs.com/config/bootstrap 10 | */ 11 | 12 | module.exports.bootstrap = async function (done) { 13 | // By convention, this is a good place to set up fake data during development. 14 | // 15 | // For example: 16 | // ``` 17 | // // Set up fake development data (or if we already have some, avast) 18 | // if (await User.count() > 0) { 19 | // return done(); 20 | // } 21 | // 22 | // await User.createEach([ 23 | // { emailAddress: 'ry@example.com', fullName: 'Ryan Dahl', }, 24 | // { emailAddress: 'rachael@example.com', fullName: 'Rachael Shaw', }, 25 | // // etc. 26 | // ]); 27 | // ``` 28 | 29 | // Don't forget to trigger `done()` when this bootstrap function's logic is finished. 30 | // (otherwise your server will never lift, since it's waiting on the bootstrap) 31 | return done() 32 | } 33 | -------------------------------------------------------------------------------- /config/custom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom configuration 3 | * (sails.config.custom) 4 | * 5 | * One-off settings specific to your application. 6 | * 7 | * For more information on custom configuration, visit: 8 | * https://sailsjs.com/config/custom 9 | */ 10 | 11 | module.exports.custom = { 12 | 13 | /*************************************************************************** 14 | * * 15 | * Any other custom config this Sails app should use during development. * 16 | * * 17 | ***************************************************************************/ 18 | // mailgunDomain: 'transactional-mail.example.com', 19 | // mailgunSecret: 'key-testkeyb183848139913858e8abd9a3', 20 | // stripeSecret: 'sk_test_Zzd814nldl91104qor5911gjald', 21 | // … 22 | 23 | } 24 | -------------------------------------------------------------------------------- /config/datastores.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Datastores 3 | * (sails.config.datastores) 4 | * 5 | * A set of datastore configurations which tell Sails where to fetch or save 6 | * data when you execute built-in model methods like `.find()` and `.create()`. 7 | * 8 | * > This file is mainly useful for configuring your development database, 9 | * > as well as any additional one-off databases used by individual models. 10 | * > Ready to go live? Head towards `config/env/production.js`. 11 | * 12 | * For more information on configuring datastores, check out: 13 | * https://sailsjs.com/config/datastores 14 | */ 15 | 16 | module.exports.datastores = { 17 | 18 | /*************************************************************************** 19 | * * 20 | * Your app's default datastore. * 21 | * * 22 | * Sails apps read and write to local disk by default, using a built-in * 23 | * database adapter called `sails-disk`. This feature is purely for * 24 | * convenience during development; since `sails-disk` is not designed for * 25 | * use in a production environment. * 26 | * * 27 | * To use a different db _in development_, follow the directions below. * 28 | * Otherwise, just leave the default datastore as-is, with no `adapter`. * 29 | * * 30 | * (For production configuration, see `config/env/production.js`.) * 31 | * * 32 | ***************************************************************************/ 33 | 34 | default: { 35 | 36 | /*************************************************************************** 37 | * * 38 | * Want to use a different database during development? * 39 | * * 40 | * 1. Choose an adapter: * 41 | * https://sailsjs.com/plugins/databases * 42 | * * 43 | * 2. Install it as a dependency of your Sails app. * 44 | * (For example: npm install sails-mysql --save) * 45 | * * 46 | * 3. Then pass it in, along with a connection URL. * 47 | * (See https://sailsjs.com/config/datastores for help.) * 48 | * * 49 | ***************************************************************************/ 50 | // adapter: 'sails-mysql', 51 | // url: 'mysql://user:password@host:port/database', 52 | 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /config/globals.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Global Variable Configuration 3 | * (sails.config.globals) 4 | * 5 | * Configure which global variables which will be exposed 6 | * automatically by Sails. 7 | * 8 | * For more information on any of these options, check out: 9 | * https://sailsjs.com/config/globals 10 | */ 11 | 12 | module.exports.globals = { 13 | 14 | /**************************************************************************** 15 | * * 16 | * Whether to expose the locally-installed Lodash as a global variable * 17 | * (`_`), making it accessible throughout your app. * 18 | * (See the link above for help.) * 19 | * * 20 | ****************************************************************************/ 21 | 22 | _: require('@sailshq/lodash'), 23 | 24 | /**************************************************************************** 25 | * * 26 | * Whether to expose the locally-installed `async` as a global variable * 27 | * (`async`), making it accessible throughout your app. * 28 | * (See the link above for help.) * 29 | * * 30 | ****************************************************************************/ 31 | 32 | async: require('async'), 33 | 34 | /**************************************************************************** 35 | * * 36 | * Whether to expose each of your app's models as global variables. * 37 | * (See the link at the top of this file for more information.) * 38 | * * 39 | ****************************************************************************/ 40 | 41 | models: true, 42 | 43 | /**************************************************************************** 44 | * * 45 | * Whether to expose the Sails app instance as a global variable (`sails`), * 46 | * making it accessible throughout your app. * 47 | * * 48 | ****************************************************************************/ 49 | 50 | sails: true 51 | 52 | } 53 | -------------------------------------------------------------------------------- /config/http.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP Server Settings 3 | * (sails.config.http) 4 | * 5 | * Configuration for the underlying HTTP server in Sails. 6 | * (for additional recommended settings, see `config/env/production.js`) 7 | * 8 | * For more information on configuration, check out: 9 | * https://sailsjs.com/config/http 10 | */ 11 | 12 | const rateLimit = require('express-rate-limit') 13 | const rateLimiter = rateLimit({ 14 | windowMs: 10 * 60 * 1000, // 10 minutes 15 | max: 100, // limit each IP to 100 requests per windowMs 16 | skip (req, res) { 17 | return !req.path.startsWith('/api') || req.path.startsWith('/api/publish') 18 | } 19 | }) 20 | 21 | const publishLimiter = rateLimit({ 22 | windowMs: 1000 * 60 * 60 * 24, // 24 hours 23 | max: 1000, // 1000 publish requests per day 24 | skip (req, res) { 25 | return !req.path.startsWith('/api/publish') 26 | } 27 | }) 28 | 29 | const allowCrossDomain = function (req, res, next) { 30 | res.header('Access-Control-Allow-Origin', 'http://localhost:8080') 31 | res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE') 32 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization') 33 | res.header('Access-Control-Allow-Credentials', 'true') 34 | next() 35 | } 36 | 37 | module.exports.http = { 38 | 39 | /**************************************************************************** 40 | * * 41 | * Sails/Express middleware to run for every HTTP request. * 42 | * (Only applies to HTTP requests -- not virtual WebSocket requests.) * 43 | * * 44 | * https://sailsjs.com/documentation/concepts/middleware * 45 | * * 46 | ****************************************************************************/ 47 | 48 | middleware: { 49 | 50 | /*************************************************************************** 51 | * * 52 | * The order in which middleware should be run for HTTP requests. * 53 | * (This Sails app's routes are handled by the "router" middleware below.) * 54 | * * 55 | ***************************************************************************/ 56 | 57 | order: [ 58 | 'allowCrossDomain', 59 | 'rateLimit', 60 | 'publishLimit', 61 | 'cookieParser', 62 | 'session', 63 | 'passportInit', 64 | 'passportSession', 65 | 'bodyParser', 66 | 'compress', 67 | 'poweredBy', 68 | 'router', 69 | 'www', 70 | 'favicon' 71 | ], 72 | rateLimit: rateLimiter, 73 | publishLimit: publishLimiter, 74 | passportInit: require('passport').initialize(), 75 | passportSession: require('passport').session(), 76 | allowCrossDomain: allowCrossDomain, 77 | 78 | /*************************************************************************** 79 | * * 80 | * The body parser that will handle incoming multipart HTTP requests. * 81 | * * 82 | * https://sailsjs.com/config/http#?customizing-the-body-parser * 83 | * * 84 | ***************************************************************************/ 85 | 86 | bodyParser: (function _configureBodyParser () { 87 | const skipper = require('skipper') 88 | const middlewareFn = skipper({ strict: true }) 89 | return middlewareFn 90 | })() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /config/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Internationalization / Localization Settings 3 | * (sails.config.i18n) 4 | * 5 | * If your app will touch people from all over the world, i18n (or internationalization) 6 | * may be an important part of your international strategy. 7 | * 8 | * For a complete list of options for Sails' built-in i18n support, see: 9 | * https://sailsjs.com/config/i-18-n 10 | * 11 | * For more info on i18n in Sails in general, check out: 12 | * https://sailsjs.com/docs/concepts/internationalization 13 | */ 14 | 15 | module.exports.i18n = { 16 | 17 | /*************************************************************************** 18 | * * 19 | * Which locales are supported? * 20 | * * 21 | ***************************************************************************/ 22 | 23 | locales: ['en', 'es', 'fr', 'de'] 24 | 25 | /**************************************************************************** 26 | * * 27 | * What is the default locale for the site? Note that this setting will be * 28 | * overridden for any request that sends an "Accept-Language" header (i.e. * 29 | * most browsers), but it's still useful if you need to localize the * 30 | * response for requests made by non-browser clients (e.g. cURL). * 31 | * * 32 | ****************************************************************************/ 33 | 34 | // defaultLocale: 'en', 35 | 36 | /**************************************************************************** 37 | * * 38 | * Path (relative to app root) of directory to store locale (translation) * 39 | * files in. * 40 | * * 41 | ****************************************************************************/ 42 | 43 | // localesDirectory: 'config/locales' 44 | 45 | } 46 | -------------------------------------------------------------------------------- /config/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Welcome": "Willkommen", 3 | "A brand new app.": "Eine neue App." 4 | } 5 | -------------------------------------------------------------------------------- /config/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Welcome": "Welcome", 3 | "A brand new app.": "A brand new app." 4 | } 5 | -------------------------------------------------------------------------------- /config/locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "Welcome": "Bienvenido", 3 | "A brand new app.": "Una nueva aplicación." 4 | } 5 | -------------------------------------------------------------------------------- /config/locales/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "Welcome": "Bienvenue", 3 | "A brand new app.": "Une toute nouvelle application." 4 | } 5 | -------------------------------------------------------------------------------- /config/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Built-in Log Configuration 3 | * (sails.config.log) 4 | * 5 | * Configure the log level for your app, as well as the transport 6 | * (Underneath the covers, Sails uses Winston for logging, which 7 | * allows for some pretty neat custom transports/adapters for log messages) 8 | * 9 | * For more information on the Sails logger, check out: 10 | * https://sailsjs.com/docs/concepts/logging 11 | */ 12 | 13 | module.exports.log = { 14 | 15 | /*************************************************************************** 16 | * * 17 | * Valid `level` configs: i.e. the minimum log level to capture with * 18 | * sails.log.*() * 19 | * * 20 | * The order of precedence for log levels from lowest to highest is: * 21 | * silly, verbose, info, debug, warn, error * 22 | * * 23 | * You may also set the level to "silent" to suppress all logs. * 24 | * * 25 | ***************************************************************************/ 26 | 27 | // level: 'info' 28 | 29 | } 30 | -------------------------------------------------------------------------------- /config/passport.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Passport configuration 3 | */ 4 | 5 | module.exports.passport = { 6 | local: { 7 | strategy: require('passport-local').Strategy 8 | }, 9 | google: { 10 | strategy: require('passport-google-oauth20').Strategy, 11 | protocol: 'oauth2', 12 | callback: '/auth/google/callback', 13 | options: { 14 | clientID: process.env.PASSPORT_GOOGLE_ID, 15 | clientSecret: process.env.PASSPORT_GOOGLE_SECRET 16 | } 17 | }, 18 | github: { 19 | strategy: require('passport-github2').Strategy, 20 | protocol: 'oauth2', 21 | callback: '/auth/github/callback', 22 | options: { 23 | clientID: process.env.PASSPORT_GITHUB_ID, 24 | clientSecret: process.env.PASSPORT_GITHUB_SECRET 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config/policies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Policy Mappings 3 | * (sails.config.policies) 4 | * 5 | * Policies are simple functions which run **before** your actions. 6 | * 7 | * For more information on configuring policies, check out: 8 | * https://sailsjs.com/docs/concepts/policies 9 | */ 10 | 11 | module.exports.policies = { 12 | 13 | /*************************************************************************** 14 | * * 15 | * Default policy for all controllers and actions, unless overridden. * 16 | * (`true` allows public access) * 17 | * * 18 | ***************************************************************************/ 19 | 20 | '*': true, 21 | 22 | UserController: { 23 | '*': true, 24 | update: [ 'sessionAuth' ], 25 | me: [ 'sessionAuth' ], 26 | regenerateSigningSecret: [ 'sessionAuth' ] 27 | }, 28 | 29 | AuthController: { 30 | '*': true, 31 | logout: [ 'sessionAuth' ], 32 | disconnect: [ 'sessionAuth' ] 33 | }, 34 | 35 | TargetController: { 36 | '*': [ 'sessionAuth' ] 37 | }, 38 | 39 | PublishKeyController: { 40 | '*': [ 'sessionAuth' ] 41 | }, 42 | 43 | BooksController: { 44 | '*': true, 45 | publish: [ 'keyAuth' ] 46 | }, 47 | 48 | AdminController: { 49 | '*': [ 'sessionAuth', 'adminAuth' ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Route Mappings 3 | * (sails.config.routes) 4 | * 5 | * Your routes tell Sails what to do each time it receives a request. 6 | * 7 | * For more information on configuring custom routes, check out: 8 | * https://sailsjs.com/anatomy/config/routes-js 9 | */ 10 | 11 | module.exports.routes = { 12 | // ╦ ╦╔═╗╔╗ ╔═╗╔═╗╔═╗╔═╗╔═╗ 13 | // ║║║║╣ ╠╩╗╠═╝╠═╣║ ╦║╣ ╚═╗ 14 | // ╚╩╝╚═╝╚═╝╩ ╩ ╩╚═╝╚═╝╚═╝ 15 | 16 | /*************************************************************************** 17 | * * 18 | * Make the view located at `views/homepage.ejs` your home page. * 19 | * * 20 | * (Alternatively, remove this and add an `index.html` file in your * 21 | * `assets` directory) * 22 | * * 23 | ***************************************************************************/ 24 | 25 | 'GET /': 'HomeController.show', 26 | 'GET /docs/:page': 'HomeController.docs', 27 | 28 | 'GET /login': { 29 | view: 'pages/login' 30 | }, 31 | 'GET /register': { 32 | view: 'pages/login' 33 | }, 34 | // figure out why proper clientside routing breaks the backend session 35 | 'GET /account': 'TargetController.show', 36 | 'GET /targets': 'TargetController.show', 37 | 'GET /keys': 'TargetController.show', 38 | 'GET /admin': 'AdminController.show', 39 | 'GET /admin/*': { 40 | action: 'admin/show', 41 | skipAssets: true 42 | }, 43 | 44 | /*************************************************************************** 45 | * * 46 | * More custom routes here... * 47 | * (See https://sailsjs.com/config/routes for examples.) * 48 | * * 49 | * If a request to a URL doesn't match any of the routes in this file, it * 50 | * is matched against "shadow routes" (e.g. blueprint routes). If it does * 51 | * not match any of those, it is matched against static assets. * 52 | * * 53 | ***************************************************************************/ 54 | 55 | // ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗ 56 | // ╠═╣╠═╝║ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗ 57 | // ╩ ╩╩ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝ 58 | 59 | 'POST /register': 'UserController.create', 60 | 'GET /logout': 'AuthController.logout', 61 | 62 | 'POST /auth/email_exists': 'AuthController.emailExists', 63 | 'POST /auth/email_available': 'AuthController.emailAvailable', 64 | 65 | 'GET /api/me': 'UserController.me', 66 | 'PATCH /api/me': 'UserController.edit', 67 | 'PATCH /api/me/regenerate_signing_secret': 'UserController.regenerateSigningSecret', 68 | 69 | 'POST /auth/:provider': 'AuthController.callback', 70 | 'POST /auth/:provider/:action': 'AuthController.callback', 71 | 72 | 'GET /auth/:provider': 'AuthController.provider', 73 | 'GET /auth/:provider/callback': 'AuthController.callback', 74 | 'GET /auth/:provider/:action': 'AuthController.callback', 75 | 76 | 'POST /api/publish': 'BooksController.publish', 77 | 78 | 'GET /api/catalog': 'CatalogController.navigation', 79 | 'GET /api/catalog/new': 'CatalogController.listNew', 80 | 'GET /api/catalog/all': 'CatalogController.listAll', 81 | 82 | 'POST /api/targets': 'TargetController.create', 83 | 'GET /api/targets': 'TargetController.list', 84 | 'PATCH /api/targets/:id': 'TargetController.edit', 85 | 'DELETE /api/targets/:id': 'TargetController.delete', 86 | 87 | 'POST /api/keys': 'PublishKeyController.create', 88 | 'GET /api/keys': 'PublishKeyController.list', 89 | 'PATCH /api/keys/:id': 'PublishKeyController.refresh', 90 | 'DELETE /api/keys/:id': 'PublishKeyController.delete', 91 | 'POST /api/keys/:id/verify': 'PublishKeyController.verify', 92 | 93 | 'GET /admin/api/users': 'AdminController.listUsers', 94 | 'GET /admin/api/publishers': 'AdminController.listPublishers', 95 | 'PATCH /admin/api/users/:id': 'AdminController.editUser', 96 | 'PATCH /admin/api/publishers/:id': 'AdminController.editPublisher', 97 | 'DELETE /admin/api/users/:id': 'AdminController.deleteUser', 98 | 'DELETE /admin/api/publishers/:id': 'AdminController.deletePublisher' 99 | 100 | // ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗ 101 | // ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗ 102 | // ╚╩╝╚═╝╚═╝╩ ╩╚═╝╚═╝╩ ╩╚═╝ 103 | 104 | // ╔╦╗╦╔═╗╔═╗ 105 | // ║║║║╚═╗║ 106 | // ╩ ╩╩╚═╝╚═╝ 107 | 108 | } 109 | -------------------------------------------------------------------------------- /config/security.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Security Settings 3 | * (sails.config.security) 4 | * 5 | * These settings affect aspects of your app's security, such 6 | * as how it deals with cross-origin requests (CORS) and which 7 | * routes require a CSRF token to be included with the request. 8 | * 9 | * For an overview of how Sails handles security, see: 10 | * https://sailsjs.com/documentation/concepts/security 11 | * 12 | * For additional options and more information, see: 13 | * https://sailsjs.com/config/security 14 | */ 15 | 16 | module.exports.security = { 17 | 18 | /*************************************************************************** 19 | * * 20 | * CORS is like a more modern version of JSONP-- it allows your application * 21 | * to circumvent browsers' same-origin policy, so that the responses from * 22 | * your Sails app hosted on one domain (e.g. example.com) can be received * 23 | * in the client-side JavaScript code from a page you trust hosted on _some * 24 | * other_ domain (e.g. trustedsite.net). * 25 | * * 26 | * For additional options and more information, see: * 27 | * https://sailsjs.com/docs/concepts/security/cors * 28 | * * 29 | ***************************************************************************/ 30 | 31 | // cors: { 32 | // allRoutes: false, 33 | // allowOrigins: '*', 34 | // allowCredentials: false, 35 | // }, 36 | 37 | /**************************************************************************** 38 | * * 39 | * By default, Sails' built-in CSRF protection is disabled to facilitate * 40 | * rapid development. But be warned! If your Sails app will be accessed by * 41 | * web browsers, you should _always_ enable CSRF protection before deploying * 42 | * to production. * 43 | * * 44 | * To enable CSRF protection, set this to `true`. * 45 | * * 46 | * For more information, see: * 47 | * https://sailsjs.com/docs/concepts/security/csrf * 48 | * * 49 | ****************************************************************************/ 50 | 51 | // csrf: false 52 | 53 | } 54 | -------------------------------------------------------------------------------- /config/session.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Session Configuration 3 | * (sails.config.session) 4 | * 5 | * Use the settings below to configure session integration in your app. 6 | * (for additional recommended settings, see `config/env/production.js`) 7 | * 8 | * For all available options, see: 9 | * https://sailsjs.com/config/session 10 | */ 11 | 12 | module.exports.session = { 13 | 14 | /*************************************************************************** 15 | * * 16 | * Session secret is automatically generated when your new app is created * 17 | * Replace at your own risk in production-- you will invalidate the cookies * 18 | * of your users, forcing them to log in again. * 19 | * * 20 | ***************************************************************************/ 21 | secret: 'b7f0374251c4d79227067c286fe97ea5' 22 | 23 | /*************************************************************************** 24 | * * 25 | * Customize when built-in session support will be skipped. * 26 | * * 27 | * (Useful for performance tuning; particularly to avoid wasting cycles on * 28 | * session management when responding to simple requests for static assets, * 29 | * like images or stylesheets.) * 30 | * * 31 | * https://sailsjs.com/config/session * 32 | * * 33 | ***************************************************************************/ 34 | // isSessionDisabled: function (req){ 35 | // return !!req.path.match(req._sails.LOOKS_LIKE_ASSET_RX); 36 | // }, 37 | 38 | } 39 | -------------------------------------------------------------------------------- /config/sockets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WebSocket Server Settings 3 | * (sails.config.sockets) 4 | * 5 | * Use the settings below to configure realtime functionality in your app. 6 | * (for additional recommended settings, see `config/env/production.js`) 7 | * 8 | * For all available options, see: 9 | * https://sailsjs.com/config/sockets 10 | */ 11 | 12 | module.exports.sockets = { 13 | 14 | /*************************************************************************** 15 | * * 16 | * `transports` * 17 | * * 18 | * The protocols or "transports" that socket clients are permitted to * 19 | * use when connecting and communicating with this Sails application. * 20 | * * 21 | * > Never change this here without also configuring `io.sails.transports` * 22 | * > in your client-side code. If the client and the server are not using * 23 | * > the same array of transports, sockets will not work properly. * 24 | * > * 25 | * > For more info, see: * 26 | * > https://sailsjs.com/docs/reference/web-sockets/socket-client * 27 | * * 28 | ***************************************************************************/ 29 | 30 | // transports: [ 'websocket' ], 31 | 32 | /*************************************************************************** 33 | * * 34 | * `beforeConnect` * 35 | * * 36 | * This custom beforeConnect function will be run each time BEFORE a new * 37 | * socket is allowed to connect, when the initial socket.io handshake is * 38 | * performed with the server. * 39 | * * 40 | * https://sailsjs.com/config/sockets#?beforeconnect * 41 | * * 42 | ***************************************************************************/ 43 | 44 | // beforeConnect: function(handshake, proceed) { 45 | // 46 | // // `true` allows the socket to connect. 47 | // // (`false` would reject the connection) 48 | // return proceed(undefined, true); 49 | // 50 | // }, 51 | 52 | /*************************************************************************** 53 | * * 54 | * `afterDisconnect` * 55 | * * 56 | * This custom afterDisconnect function will be run each time a socket * 57 | * disconnects * 58 | * * 59 | ***************************************************************************/ 60 | 61 | // afterDisconnect: function(session, socket, done) { 62 | // 63 | // // By default: do nothing. 64 | // // (but always trigger the callback) 65 | // return done(); 66 | // 67 | // }, 68 | 69 | /*************************************************************************** 70 | * * 71 | * Whether to expose a 'GET /__getcookie' route that sets an HTTP-only * 72 | * session cookie. * 73 | * * 74 | ***************************************************************************/ 75 | 76 | // grant3rdPartyCookie: true, 77 | 78 | } 79 | -------------------------------------------------------------------------------- /config/views.js: -------------------------------------------------------------------------------- 1 | /** 2 | * View Engine Configuration 3 | * (sails.config.views) 4 | * 5 | * Server-sent views are a secure and effective way to get your app up 6 | * and running. Views are normally served from actions. Below, you can 7 | * configure your templating language/framework of choice and configure 8 | * Sails' layout support. 9 | * 10 | * For details on available options for configuring server-side views, check out: 11 | * https://sailsjs.com/config/views 12 | * 13 | * For more background information on views and partials in Sails, check out: 14 | * https://sailsjs.com/docs/concepts/views 15 | */ 16 | 17 | module.exports.views = { 18 | 19 | /*************************************************************************** 20 | * * 21 | * Extension to use for your views. When calling `res.view()` in an action, * 22 | * you can leave this extension off. For example, calling * 23 | * `res.view('homepage')` will (using default settings) look for a * 24 | * `views/homepage.ejs` file. * 25 | * * 26 | ***************************************************************************/ 27 | 28 | // extension: 'ejs', 29 | 30 | /*************************************************************************** 31 | * * 32 | * The path (relative to the views directory, and without extension) to * 33 | * the default layout file to use, or `false` to disable layouts entirely. * 34 | * * 35 | * Note that layouts only work with the built-in EJS view engine! * 36 | * * 37 | ***************************************************************************/ 38 | 39 | layout: 'layouts/layout' 40 | 41 | } 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | # River of Ebooks Integrations 2 | ## Information on existing RoE integrations with other websites and services 3 | 4 | 5 | ### - readthedocs.org 6 | [view on github](https://github.com/ghowardsit/readthedocs.org) 7 | New ebooks published using ReadTheDocs will automatically be sent to the River of Ebooks. 8 | 9 | ### - Pressbooks 10 | [view on github](https://github.com/villa7/roe-pressbooks) 11 | When exporting books, authors will have the option to also send the book metadata to the River. 12 | 13 | 14 | ## Information on building your own integration 15 | 16 | To publish ebook metadata through the River of Ebooks, you will first need to register an account and then create a publisher. 17 | Verifying your ownership of the publisher's domain will increase the chance that your publisher is whitelisted. Once whitelisted, you will be able to use your publisher's appid (key) and secret to POST metadata through the River. You can view the [API documentation here](docs/api). Publishing books is easy - and once published, each new ebook will be available for downstream consumers to find. 18 | -------------------------------------------------------------------------------- /docs/webhooks.md: -------------------------------------------------------------------------------- 1 | # River of Ebooks webhooks and signed requests 2 | ## Information on how to receive new publications from RoE 3 | 4 | To have RoE send your service any newly published or updated ebooks, you will first need to register an account and then create a Push URI. RoE will send a POST request containing the OPDS2 metadata from any published or updated book to each valid URL. The POST body structure can be viewed in the [API documentation](docs/api). 5 | 6 | 7 | ## Information on how to verify that requests are sent by RoE 8 | 9 | 1. Grab your Signing Secret from the bottom of the 'My account' page. In this example, the Signing Secret is `919ac0b6c07b50`. Additionally, extract the raw request body from the request. 10 | ```js 11 | signing_secret = 'ROE_SIGNING_SECRET' // set this as an environment variable 12 | >>> '919ac0b6c07b50' 13 | request_body = request.body() 14 | >>> {"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ... 15 | ``` 16 | 17 | 2. Extract the timestamp header (`1551832182955` in this example). The signature depends on the timestamp to protect against replay attacks. While you're extracting the timestamp, check to make sure that the request occurred recently. In this example, we verify that the timestamp does not differ from local time by more than five minutes. 18 | ```js 19 | timestamp = request.headers['X-RoE-Request-Timestamp'] 20 | >>> 1551832182955 21 | if (absolute_value(time.time() - timestamp) > 60 * 5) 22 | // The request timestamp is more than five minutes from local time. 23 | // It could be a replay attack, so let's ignore it. 24 | return 25 | ``` 26 | 27 | 3. Concatenate the version number (`v0`), the timestamp (`1551832182955`), and the request body (`{"metadata":{"@type":"http...`) together, using a colon (`:`) as a delimiter. 28 | ```js 29 | sig_basestring = 'v0:' + timestamp + ':' + request_body 30 | >>> 'v0:1551832182955:{"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ...' 31 | ``` 32 | 33 | 4. Then hash the resulting string, using the signing secret as a key, and take the hex digest of the hash. In this example, we compute a hex digest of `1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`. The full signature is formed by prefixing the hex digest with `v0=`, to make `v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`. 34 | ```js 35 | my_signature = 'v0=' + hmac.compute_hash_sha256( 36 | signing_secret, 37 | sig_basestring 38 | ).hexdigest() 39 | >>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23' 40 | ``` 41 | 42 | 5. Compare the resulting signature to the header on the request. 43 | ```js 44 | signature = request.headers['X-RoE-Signature'] 45 | >>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23' 46 | if (hmac.compare(my_signature, signature)) { 47 | deal_with_request(request) 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [{ 3 | name: 'roe-base', 4 | script: 'app.js', 5 | instances: 2, 6 | autorestart: true, 7 | watch: false, 8 | env: { 9 | NODE_ENV: 'development' 10 | }, 11 | env_production: { 12 | NODE_ENV: 'production', 13 | DATABASE_CONNECTION: process.env.DATABASE_CONNECTION, 14 | PASSPORT_GITHUB_ID: process.env.PASSPORT_GITHUB_ID, 15 | PASSPORT_GITHUB_SECRET: process.env.PASSPORT_GITHUB_SECRET, 16 | PASSPORT_GOOGLE_ID: process.env.PASSPORT_GOOGLE_ID, 17 | PASSPORT_GOOGLE_SECRET: process.env.PASSPORT_GOOGLE_SECRET 18 | } 19 | }] 20 | } 21 | -------------------------------------------------------------------------------- /install.md: -------------------------------------------------------------------------------- 1 | # Setup instructions 2 | 3 | ### Version info 4 | This app was originally generated on Tue Oct 16 2018 01:21:31 GMT+0000 (UTC) using Sails v1.0.2. 5 | 6 | 7 | ### Setup 8 | a [Sails v1](https://sailsjs.com) application 9 | [![Build Status](https://travis-ci.org/miacona96/RoE-pipe.svg?branch=master)](https://travis-ci.org/miacona96/RoE-pipe) 10 | 11 | #### Standalone 12 | 13 | 1. Standard npm install 14 | ``` 15 | git clone https://github.com/EbookFoundation/river-of-ebooks 16 | cd river-of-ebooks 17 | npm i 18 | ``` 19 | 20 | 2. Configure environment variables in /etc/environemnt 21 | ``` 22 | PASSPORT_GOOGLE_ID 23 | PASSPORT_GOOGLE_SECRET 24 | PASSPORT_GITHUB_ID 25 | PASSPORT_GITHUB_SECRET 26 | DATABASE_CONNECTION 27 | ``` 28 | 29 | 3. Run database migrations 30 | ``` 31 | npm run db:migrate 32 | ``` 33 | 34 | 4. Build public content 35 | ``` 36 | npm run build 37 | ``` 38 | 39 | 5. Start server 40 | ``` 41 | npm start 42 | ``` 43 | 44 | #### Elastic Beanstalk 45 | 46 | 1. Clone repo 47 | ``` 48 | git clone https://github.com/EbookFoundation/river-of-ebooks 49 | cd river-of-ebooks 50 | ``` 51 | 52 | 2. Deploy to environment 53 | ``` 54 | eb deploy environment_name 55 | ``` 56 | 57 | 3. Configure environment variables on elastic beanstalk 58 | ``` 59 | PASSPORT_GOOGLE_ID 60 | PASSPORT_GOOGLE_SECRET 61 | PASSPORT_GITHUB_ID 62 | PASSPORT_GITHUB_SECRET 63 | DATABASE_CONNECTION 64 | ``` 65 | 66 | 4. Run database migrations 67 | ``` 68 | npm run db:migrate 69 | ``` 70 | 71 | 5. Build public content 72 | ``` 73 | npm run build 74 | ``` 75 | 76 | 6. Start server 77 | ``` 78 | npm start 79 | ``` 80 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: 'pg', 3 | connection: process.env.DATABASE_CONNECTION 4 | } 5 | -------------------------------------------------------------------------------- /migrations/20181119144327_create_initial_tables.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.createTable('user', t => { 4 | t.increments('id').primary() 5 | t.string('email').notNullable() 6 | t.bigInteger('created_at') 7 | t.bigInteger('updated_at') 8 | }), 9 | knex.schema.createTable('passport', t => { 10 | t.increments('id').primary() 11 | t.string('protocol').notNullable() 12 | t.string('password') 13 | t.string('accesstoken') 14 | t.string('provider') 15 | t.string('identifier') 16 | t.json('tokens') 17 | t.integer('user').notNullable().references('user.id').onDelete('CASCADE').onUpdate('CASCADE') 18 | t.bigInteger('created_at') 19 | t.bigInteger('updated_at') 20 | }), 21 | knex.schema.createTable('targeturl', t => { 22 | t.increments('id').primary() 23 | t.integer('user').notNullable().references('user.id').onDelete('CASCADE').onUpdate('CASCADE') 24 | t.string('url') 25 | t.bigInteger('created_at') 26 | t.bigInteger('updated_at') 27 | }), 28 | knex.schema.createTable('book', t => { 29 | t.increments('id').primary() 30 | t.string('source') 31 | t.string('storage') 32 | t.string('title').notNullable() 33 | t.string('author') 34 | t.string('version') 35 | t.string('isbn') 36 | t.bigInteger('created_at') 37 | t.bigInteger('updated_at') 38 | }) 39 | ]) 40 | } 41 | 42 | exports.down = function (knex, Promise) { 43 | return Promise.all([ 44 | knex.schema.dropTable('book'), 45 | knex.schema.dropTable('targeturl'), 46 | knex.schema.dropTable('passport'), 47 | knex.schema.dropTable('user') 48 | ]) 49 | } 50 | -------------------------------------------------------------------------------- /migrations/20181119152303_timestamps_to_bigint.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.alterTable('user', t => { 4 | t.bigInteger('created_at').alter() 5 | t.bigInteger('updated_at').alter() 6 | }), 7 | knex.schema.alterTable('passport', t => { 8 | t.bigInteger('created_at').alter() 9 | t.bigInteger('updated_at').alter() 10 | }), 11 | knex.schema.alterTable('targeturl', t => { 12 | t.bigInteger('created_at').alter() 13 | t.bigInteger('updated_at').alter() 14 | }), 15 | knex.schema.alterTable('book', t => { 16 | t.bigInteger('created_at').alter() 17 | t.bigInteger('updated_at').alter() 18 | }) 19 | ]) 20 | } 21 | 22 | exports.down = function (knex, Promise) { 23 | return Promise.all([ 24 | knex.schema.alterTable('user', t => { 25 | t.integer('created_at').alter() 26 | t.integer('updated_at').alter() 27 | }), 28 | knex.schema.alterTable('passport', t => { 29 | t.integer('created_at').alter() 30 | t.integer('updated_at').alter() 31 | }), 32 | knex.schema.alterTable('targeturl', t => { 33 | t.integer('created_at').alter() 34 | t.integer('updated_at').alter() 35 | }), 36 | knex.schema.alterTable('book', t => { 37 | t.integer('created_at').alter() 38 | t.integer('updated_at').alter() 39 | }) 40 | ]) 41 | } 42 | -------------------------------------------------------------------------------- /migrations/20181119183500_add_filters_to_targeturl.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('targeturl', t => { 4 | t.string('title') 5 | t.string('author') 6 | t.string('publisher') 7 | t.string('isbn') 8 | }) 9 | ]) 10 | } 11 | 12 | exports.down = function (knex, Promise) { 13 | return Promise.all([ 14 | knex.schema.table('targeturl', t => { 15 | t.dropColumns('title', 'author', 'publisher', 'isbn') 16 | }) 17 | ]) 18 | } 19 | -------------------------------------------------------------------------------- /migrations/20190220123908_AddPublisherToBooks.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('book', t => { 4 | t.string('publisher') 5 | }) 6 | ]) 7 | } 8 | 9 | exports.down = function (knex, Promise) { 10 | return Promise.all([ 11 | knex.schema.table('book', t => { 12 | t.dropColumns('publisher') 13 | }) 14 | ]) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/20190220133443_add_srcHost_to_book.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('book', t => { 4 | t.string('hostname') 5 | }) 6 | ]) 7 | } 8 | 9 | exports.down = function (knex, Promise) { 10 | return Promise.all([ 11 | knex.schema.table('book', t => { 12 | t.dropColumns('hostname') 13 | }) 14 | ]) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/20190224022422_add_admin_users.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('user', t => { 4 | t.boolean('admin').defaultTo(false) 5 | }), 6 | knex.schema.createTable('publishkey', t => { 7 | t.increments('id').primary() 8 | t.integer('user').notNullable().references('user.id').onDelete('CASCADE').onUpdate('CASCADE') 9 | t.string('name') 10 | t.string('url') 11 | t.string('appid') 12 | t.string('secret') 13 | t.string('verification_key') 14 | t.boolean('whitelisted').defaultTo(false) 15 | t.boolean('verified').defaultTo(false) 16 | t.bigInteger('created_at') 17 | t.bigInteger('updated_at') 18 | }) 19 | ]) 20 | } 21 | 22 | exports.down = function (knex, Promise) { 23 | return Promise.all([ 24 | knex.schema.table('user', t => { 25 | t.dropColumns('admin') 26 | }), 27 | knex.schema.dropTable('publishkey') 28 | ]) 29 | } 30 | -------------------------------------------------------------------------------- /migrations/20190226195925_publishkey_timestamps_bigint.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.alterTable('publishkey', t => { 4 | t.bigInteger('created_at').alter() 5 | t.bigInteger('updated_at').alter() 6 | }) 7 | ]) 8 | } 9 | 10 | exports.down = function (knex, Promise) { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /migrations/20190305170728_AddSigningSecretToUser.js: -------------------------------------------------------------------------------- 1 | const { generateToken } = require('../api/util') 2 | 3 | exports.up = function (knex, Promise) { 4 | return Promise.all([ 5 | knex.schema.table('user', t => { 6 | t.string('signing_secret') 7 | }), 8 | initUserSecrets(knex, Promise) 9 | ]) 10 | } 11 | 12 | exports.down = function (knex, Promise) { 13 | return Promise.all([ 14 | knex.schema.table('user', t => { 15 | t.dropColumns('signing_secret') 16 | }) 17 | ]) 18 | } 19 | 20 | async function initUserSecrets (knex, Promise) { 21 | const users = await knex('user').whereNull('signing_secret') 22 | for (const user of users) { 23 | await knex('user').where({ id: user.id }).update({ signing_secret: generateToken({ bytes: 24 }) }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /migrations/20190314120819_store_opds_in_db.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('book', t => { 4 | t.json('opds') 5 | t.renameColumn('isbn', 'identifier') 6 | t.dropColumns('storage') 7 | }) 8 | ]) 9 | } 10 | 11 | exports.down = function (knex, Promise) { 12 | return Promise.all([ 13 | knex.schema.table('book', t => { 14 | t.dropColumns('opds') 15 | t.renameColumn('identifier', 'isbn') 16 | t.string('storage') 17 | }) 18 | ]) 19 | } 20 | -------------------------------------------------------------------------------- /migrations/20190314123654_remove_book_source.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('book', t => { 4 | t.dropColumns('source') 5 | }) 6 | ]) 7 | } 8 | 9 | exports.down = function (knex, Promise) { 10 | return Promise.all([ 11 | knex.schema.table('book', t => { 12 | t.string('source') 13 | }) 14 | ]) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/20190401161204_add_book_tags.js: -------------------------------------------------------------------------------- 1 | exports.up = function (knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.table('book', t => { 4 | t.string('tags') 5 | }), 6 | knex.schema.table('targeturl', t => { 7 | t.string('tags') 8 | }) 9 | ]) 10 | } 11 | 12 | exports.down = function (knex, Promise) { 13 | return Promise.all([ 14 | knex.schema.table('book', t => { 15 | t.dropColumns('tags') 16 | }), 17 | knex.schema.table('targeturl', t => { 18 | t.dropColumns('tags') 19 | }) 20 | ]) 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roe-base", 3 | "private": true, 4 | "version": "0.0.1", 5 | "description": "a Sails application", 6 | "keywords": [], 7 | "scripts": { 8 | "start": "npm run forever", 9 | "start:dev": "npm-run-all --parallel open:client lift", 10 | "start:debug": "npm-run-all --parallel open:client debug", 11 | "start:prod": "npm-run-all --parallel build:prod lift", 12 | "start:client": "webpack-dev-server --mode development", 13 | "start:eb": "npm run db:migrate && npm run build:prod && npm run lift:prod", 14 | "lift": "sails lift", 15 | "lift:prod": "sails lift --prod", 16 | "build": "npm run build:prod", 17 | "build:dev": "webpack --mode development", 18 | "build:prod": "webpack --mode production", 19 | "clean": "rimraf .tmp && mkdirp .tmp/public", 20 | "forever": "./node_modules/.bin/pm2-runtime start ecosystem.config.js --env production", 21 | "stop": "./node_modules/.bin/pm2-runtime delete roe-base", 22 | "test": "npm run lint && npm run custom-tests && echo 'Done.'", 23 | "lint": "standard && echo '✔ Your .js files look good.'", 24 | "debug": "node --inspect app.js", 25 | "custom-tests": "echo 'Nothing yet'", 26 | "db:migrate": "knex migrate:latest", 27 | "db:rollback": "knex migrate:rollback", 28 | "g:migration": "knex migrate:make" 29 | }, 30 | "dependencies": { 31 | "@sailshq/connect-redis": "^3.2.1", 32 | "@sailshq/lodash": "^3.10.3", 33 | "@sailshq/socket.io-redis": "^5.2.0", 34 | "async": "2.0.1", 35 | "base64url": "^3.0.0", 36 | "bcrypt": "^3.0.2", 37 | "chai": "^4.2.0", 38 | "eslint-plugin-react": "^7.11.1", 39 | "express-rate-limit": "^3.2.1", 40 | "grunt": "^1.0.3", 41 | "knex": "^0.15.2", 42 | "passport": "^0.4.0", 43 | "passport-github2": "^0.1.11", 44 | "passport-google-oauth20": "^1.0.0", 45 | "passport-local": "^1.0.0", 46 | "pm2": "^3.2.2", 47 | "react": "^16.6.0", 48 | "react-dom": "^16.6.0", 49 | "request": "^2.88.0", 50 | "sails": "^1.0.2", 51 | "sails-hook-grunt": "^3.0.2", 52 | "sails-hook-orm": "^2.1.1", 53 | "sails-hook-sockets": "^1.4.0", 54 | "sails-postgresql": "^1.0.2", 55 | "showdown": "^1.9.0", 56 | "webpack": "^4.23.1", 57 | "webpack-cli": "^3.1.2", 58 | "@babel/core": "^7.1.2", 59 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 60 | "@babel/polyfill": "^7.0.0", 61 | "@babel/preset-env": "^7.1.0", 62 | "@babel/preset-react": "^7.0.0", 63 | "babel-eslint": "^10.0.1", 64 | "babel-loader": "^8.0.4", 65 | "css-loader": "^1.0.1", 66 | "file-saver": "^2.0.1", 67 | "html-webpack-plugin": "^3.2.0", 68 | "mini-css-extract-plugin": "^0.4.4", 69 | "mocha": "^5.2.0", 70 | "node-sass": "^4.9.4", 71 | "npm-run-all": "^4.1.3", 72 | "react-router-dom": "^4.3.1", 73 | "rimraf": "^2.6.2", 74 | "sass-loader": "^7.1.0", 75 | "standard": "^12.0.1", 76 | "style-loader": "^0.23.1", 77 | "webpack-dev-server": "^3.1.10" 78 | }, 79 | "main": "app.js", 80 | "repository": { 81 | "type": "git", 82 | "url": "git://github.com/ebookfoundation/riverofebooks.git" 83 | }, 84 | "author": "vagrant", 85 | "license": "", 86 | "engines": { 87 | "node": ">=8.10" 88 | }, 89 | "standard": { 90 | "globals": [ 91 | "sails", 92 | "User", 93 | "Book", 94 | "Passport", 95 | "TargetUrl", 96 | "PublishKey", 97 | "_" 98 | ], 99 | "env": [ 100 | "mocha" 101 | ], 102 | "parser": "babel-eslint" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tasks/config/babel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/babel` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Transpile >=ES6 code for broader browser compatibility. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/babel.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('babel', { 14 | dist: { 15 | options: { 16 | presets: [require('sails-hook-grunt/accessible/babel-preset-env')] 17 | }, 18 | files: [ 19 | { 20 | expand: true, 21 | cwd: '.tmp/public', 22 | src: ['js/**/*.js'], 23 | dest: '.tmp/public' 24 | } 25 | ] 26 | } 27 | }) 28 | 29 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 30 | // This Grunt plugin is part of the default asset pipeline in Sails, 31 | // so it's already been automatically loaded for you at this point. 32 | // 33 | // Of course, you can always remove this Grunt plugin altogether by 34 | // deleting this file. But check this out: you can also use your 35 | // _own_ custom version of this Grunt plugin. 36 | // 37 | // Here's how: 38 | // 39 | // 1. Install it as a local dependency of your Sails app: 40 | // ``` 41 | // $ npm install grunt-babel --save-dev --save-exact 42 | // ``` 43 | // 44 | // 45 | // 2. Then uncomment the following code: 46 | // 47 | // ``` 48 | // // Load Grunt plugin from the node_modules/ folder. 49 | // grunt.loadNpmTasks('grunt-babel'); 50 | // ``` 51 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 52 | } 53 | -------------------------------------------------------------------------------- /tasks/config/clean.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/clean` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Remove generated files and folders. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/clean.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('clean', { 14 | dev: ['.tmp/public/**'], 15 | build: ['www'], 16 | afterBuildProd: [ 17 | 'www/concat', 18 | 'www/min', 19 | 'www/hash', 20 | 'www/js', 21 | 'www/styles', 22 | 'www/templates', 23 | 'www/dependencies' 24 | ] 25 | }) 26 | 27 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 28 | // This Grunt plugin is part of the default asset pipeline in Sails, 29 | // so it's already been automatically loaded for you at this point. 30 | // 31 | // Of course, you can always remove this Grunt plugin altogether by 32 | // deleting this file. But check this out: you can also use your 33 | // _own_ custom version of this Grunt plugin. 34 | // 35 | // Here's how: 36 | // 37 | // 1. Install it as a local dependency of your Sails app: 38 | // ``` 39 | // $ npm install grunt-contrib-clean --save-dev --save-exact 40 | // ``` 41 | // 42 | // 43 | // 2. Then uncomment the following code: 44 | // 45 | // ``` 46 | // // Load Grunt plugin from the node_modules/ folder. 47 | // grunt.loadNpmTasks('grunt-contrib-clean'); 48 | // ``` 49 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 50 | } 51 | -------------------------------------------------------------------------------- /tasks/config/coffee.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/coffee` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Compile CoffeeScript files located in `assets/js` into Javascript 7 | * and generate new `.js` files in `.tmp/public/js`. 8 | * 9 | * For more information, see: 10 | * https://sailsjs.com/anatomy/tasks/config/coffee.js 11 | * 12 | */ 13 | module.exports = function (grunt) { 14 | grunt.config.set('coffee', { 15 | dev: { 16 | options: { 17 | bare: true, 18 | sourceMap: true, 19 | sourceRoot: './' 20 | }, 21 | files: [{ 22 | expand: true, 23 | cwd: 'assets/js/', 24 | src: ['**/*.coffee'], 25 | dest: '.tmp/public/js/', 26 | ext: '.js' 27 | }] 28 | } 29 | }) 30 | 31 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 32 | // This Grunt plugin is part of the default asset pipeline in Sails, 33 | // so it's already been automatically loaded for you at this point. 34 | // 35 | // Of course, you can always remove this Grunt plugin altogether by 36 | // deleting this file. But check this out: you can also use your 37 | // _own_ custom version of this Grunt plugin. 38 | // 39 | // Here's how: 40 | // 41 | // 1. Install it as a local dependency of your Sails app: 42 | // ``` 43 | // $ npm install grunt-contrib-coffee --save-dev --save-exact 44 | // ``` 45 | // 46 | // 47 | // 2. Then uncomment the following code: 48 | // 49 | // ``` 50 | // // Load Grunt plugin from the node_modules/ folder. 51 | // grunt.loadNpmTasks('grunt-contrib-coffee'); 52 | // ``` 53 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 54 | } 55 | -------------------------------------------------------------------------------- /tasks/config/concat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/concat` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * An intermediate step to generate monolithic files that can 7 | * then be passed in to `uglify` and/or `cssmin` for minification. 8 | * 9 | * For more information, see: 10 | * https://sailsjs.com/anatomy/tasks/config/concat.js 11 | * 12 | */ 13 | module.exports = function (grunt) { 14 | grunt.config.set('concat', { 15 | js: { 16 | src: require('../pipeline').jsFilesToInject, 17 | dest: '.tmp/public/concat/production.js' 18 | }, 19 | css: { 20 | src: require('../pipeline').cssFilesToInject, 21 | dest: '.tmp/public/concat/production.css' 22 | } 23 | }) 24 | 25 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 26 | // This Grunt plugin is part of the default asset pipeline in Sails, 27 | // so it's already been automatically loaded for you at this point. 28 | // 29 | // Of course, you can always remove this Grunt plugin altogether by 30 | // deleting this file. But check this out: you can also use your 31 | // _own_ custom version of this Grunt plugin. 32 | // 33 | // Here's how: 34 | // 35 | // 1. Install it as a local dependency of your Sails app: 36 | // ``` 37 | // $ npm install grunt-contrib-concat --save-dev --save-exact 38 | // ``` 39 | // 40 | // 41 | // 2. Then uncomment the following code: 42 | // 43 | // ``` 44 | // // Load Grunt plugin from the node_modules/ folder. 45 | // grunt.loadNpmTasks('grunt-contrib-concat'); 46 | // ``` 47 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 48 | } 49 | -------------------------------------------------------------------------------- /tasks/config/copy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/copy` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Copy files and/or folders. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/copy.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('copy', { 14 | dev: { 15 | files: [{ 16 | expand: true, 17 | cwd: './assets', 18 | src: ['**/*.!(coffee|less)'], 19 | dest: '.tmp/public' 20 | }] 21 | }, 22 | build: { 23 | files: [{ 24 | expand: true, 25 | cwd: '.tmp/public', 26 | src: ['**/*'], 27 | dest: 'www' 28 | }] 29 | }, 30 | beforeLinkBuildProd: { 31 | files: [{ 32 | expand: true, 33 | cwd: '.tmp/public/hash', 34 | src: ['**/*'], 35 | dest: '.tmp/public/dist' 36 | }] 37 | } 38 | }) 39 | 40 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 41 | // This Grunt plugin is part of the default asset pipeline in Sails, 42 | // so it's already been automatically loaded for you at this point. 43 | // 44 | // Of course, you can always remove this Grunt plugin altogether by 45 | // deleting this file. But check this out: you can also use your 46 | // _own_ custom version of this Grunt plugin. 47 | // 48 | // Here's how: 49 | // 50 | // 1. Install it as a local dependency of your Sails app: 51 | // ``` 52 | // $ npm install grunt-contrib-copy --save-dev --save-exact 53 | // ``` 54 | // 55 | // 56 | // 2. Then uncomment the following code: 57 | // 58 | // ``` 59 | // // Load Grunt plugin from the node_modules/ folder. 60 | // grunt.loadNpmTasks('grunt-contrib-copy'); 61 | // ``` 62 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 63 | } 64 | -------------------------------------------------------------------------------- /tasks/config/cssmin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/cssmin` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Together with the `concat` task, this is the final step that minifies 7 | * all CSS files from `assets/styles/` (and potentially your LESS importer 8 | * file from `assets/styles/importer.less`) 9 | * 10 | * For more information, see: 11 | * https://sailsjs.com/anatomy/tasks/config/cssmin.js 12 | * 13 | */ 14 | module.exports = function (grunt) { 15 | grunt.config.set('cssmin', { 16 | dist: { 17 | src: ['.tmp/public/concat/production.css'], 18 | dest: '.tmp/public/min/production.min.css' 19 | } 20 | }) 21 | 22 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 23 | // This Grunt plugin is part of the default asset pipeline in Sails, 24 | // so it's already been automatically loaded for you at this point. 25 | // 26 | // Of course, you can always remove this Grunt plugin altogether by 27 | // deleting this file. But check this out: you can also use your 28 | // _own_ custom version of this Grunt plugin. 29 | // 30 | // Here's how: 31 | // 32 | // 1. Install it as a local dependency of your Sails app: 33 | // ``` 34 | // $ npm install grunt-contrib-cssmin --save-dev --save-exact 35 | // ``` 36 | // 37 | // 38 | // 2. Then uncomment the following code: 39 | // 40 | // ``` 41 | // // Load Grunt plugin from the node_modules/ folder. 42 | // grunt.loadNpmTasks('grunt-contrib-cssmin'); 43 | // ``` 44 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 45 | } 46 | -------------------------------------------------------------------------------- /tasks/config/hash.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/hash` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Implement cache-busting for minified CSS and JavaScript files. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/hash.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('hash', { 14 | options: { 15 | mapping: '', 16 | srcBasePath: '', 17 | destBasePath: '', 18 | flatten: false, 19 | hashLength: 8, 20 | hashFunction: function (source, encoding) { 21 | if (!source || !encoding) { 22 | throw new Error('Consistency violation: Cannot compute unique hash for production .css/.js cache-busting suffix, because `source` and/or `encoding` are falsey-- but they should be truthy strings! Here they are, respectively:\nsource: ' + require('util').inspect(source, { depth: null }) + '\nencoding: ' + require('util').inspect(encoding, { depth: null })) 23 | } 24 | return require('crypto').createHash('sha1').update(source, encoding).digest('hex') 25 | } 26 | }, 27 | js: { 28 | src: '.tmp/public/min/*.js', 29 | dest: '.tmp/public/hash/' 30 | }, 31 | css: { 32 | src: '.tmp/public/min/*.css', 33 | dest: '.tmp/public/hash/' 34 | } 35 | }) 36 | 37 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 38 | // This Grunt plugin is part of the default asset pipeline in Sails, 39 | // so it's already been automatically loaded for you at this point. 40 | // 41 | // Of course, you can always remove this Grunt plugin altogether by 42 | // deleting this file. But check this out: you can also use your 43 | // _own_ custom version of this Grunt plugin. 44 | // 45 | // Here's how: 46 | // 47 | // 1. Install it as a local dependency of your Sails app: 48 | // ``` 49 | // $ npm install grunt-hash --save-dev --save-exact 50 | // ``` 51 | // 52 | // 53 | // 2. Then uncomment the following code: 54 | // 55 | // ``` 56 | // // Load Grunt plugin from the node_modules/ folder. 57 | // grunt.loadNpmTasks('grunt-hash'); 58 | // ``` 59 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 60 | } 61 | -------------------------------------------------------------------------------- /tasks/config/jst.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/jst` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Precompile HTML templates using Underscore/Lodash notation. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/jst.js 10 | * 11 | */ 12 | 13 | module.exports = function (grunt) { 14 | grunt.config.set('jst', { 15 | dev: { 16 | 17 | // To use other sorts of templates, specify a regexp like the example below: 18 | // options: { 19 | // templateSettings: { 20 | // interpolate: /\{\{(.+?)\}\}/g 21 | // } 22 | // }, 23 | 24 | // Note that the interpolate setting above is simply an example of overwriting lodash's 25 | // default interpolation. If you want to parse templates with the default _.template behavior 26 | // (i.e. using
    <%= this.id %>
    ), there's no need to overwrite `templateSettings.interpolate`. 27 | 28 | files: { 29 | // e.g. 30 | // 'relative/path/from/gruntfile/to/compiled/template/destination' : ['relative/path/to/sourcefiles/**/*.html'] 31 | '.tmp/public/jst.js': require('../pipeline').templateFilesToInject 32 | } 33 | } 34 | }) 35 | 36 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 37 | // This Grunt plugin is part of the default asset pipeline in Sails, 38 | // so it's already been automatically loaded for you at this point. 39 | // 40 | // Of course, you can always remove this Grunt plugin altogether by 41 | // deleting this file. But check this out: you can also use your 42 | // _own_ custom version of this Grunt plugin. 43 | // 44 | // Here's how: 45 | // 46 | // 1. Install it as a local dependency of your Sails app: 47 | // ``` 48 | // $ npm install grunt-contrib-jst --save-dev --save-exact 49 | // ``` 50 | // 51 | // 52 | // 2. Then uncomment the following code: 53 | // 54 | // ``` 55 | // // Load Grunt plugin from the node_modules/ folder. 56 | // grunt.loadNpmTasks('grunt-contrib-jst'); 57 | // ``` 58 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 59 | } 60 | -------------------------------------------------------------------------------- /tasks/config/less.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/less` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Compile your LESS files into a CSS stylesheet. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/less.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('less', { 14 | dev: { 15 | files: [{ 16 | expand: true, 17 | cwd: 'assets/styles/', 18 | src: ['importer.less'], 19 | dest: '.tmp/public/styles/', 20 | ext: '.css' 21 | }] 22 | } 23 | }) 24 | 25 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 26 | // This Grunt plugin is part of the default asset pipeline in Sails, 27 | // so it's already been automatically loaded for you at this point. 28 | // 29 | // Of course, you can always remove this Grunt plugin altogether by 30 | // deleting this file. But check this out: you can also use your 31 | // _own_ custom version of this Grunt plugin. 32 | // 33 | // Here's how: 34 | // 35 | // 1. Install it as a local dependency of your Sails app: 36 | // ``` 37 | // $ npm install grunt-contrib-less --save-dev --save-exact 38 | // ``` 39 | // 40 | // 41 | // 2. Then uncomment the following code: 42 | // 43 | // ``` 44 | // // Load Grunt plugin from the node_modules/ folder. 45 | // grunt.loadNpmTasks('grunt-contrib-less'); 46 | // ``` 47 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 48 | } 49 | -------------------------------------------------------------------------------- /tasks/config/sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/sync` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Synchronize files from the `assets` folder to `.tmp/public`, 7 | * smashing anything that's already there. 8 | * 9 | * For more information, see: 10 | * https://sailsjs.com/anatomy/tasks/config/sync.js 11 | * 12 | */ 13 | module.exports = function (grunt) { 14 | grunt.config.set('sync', { 15 | dev: { 16 | files: [{ 17 | cwd: './assets', 18 | src: ['**/*.!(coffee|less)'], 19 | dest: '.tmp/public' 20 | }] 21 | } 22 | }) 23 | 24 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 25 | // This Grunt plugin is part of the default asset pipeline in Sails, 26 | // so it's already been automatically loaded for you at this point. 27 | // 28 | // Of course, you can always remove this Grunt plugin altogether by 29 | // deleting this file. But check this out: you can also use your 30 | // _own_ custom version of this Grunt plugin. 31 | // 32 | // Here's how: 33 | // 34 | // 1. Install it as a local dependency of your Sails app: 35 | // ``` 36 | // $ npm install grunt-sync --save-dev --save-exact 37 | // ``` 38 | // 39 | // 40 | // 2. Then uncomment the following code: 41 | // 42 | // ``` 43 | // // Load Grunt plugin from the node_modules/ folder. 44 | // grunt.loadNpmTasks('grunt-sync'); 45 | // ``` 46 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 47 | } 48 | -------------------------------------------------------------------------------- /tasks/config/uglify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/uglify` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Minify client-side JavaScript files using UglifyES. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/uglify.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('uglify', { 14 | dist: { 15 | src: ['.tmp/public/concat/production.js'], 16 | dest: '.tmp/public/min/production.min.js' 17 | }, 18 | options: { 19 | mangle: { 20 | reserved: [ 21 | 'AsyncFunction', 22 | 'SailsSocket', 23 | 'Promise', 24 | 'File', 25 | 'Location', 26 | 'RttcRefPlaceholder' 27 | ], 28 | keep_fnames: true//eslint-disable-line 29 | }, 30 | compress: { 31 | keep_fnames: true//eslint-disable-line 32 | } 33 | } 34 | }) 35 | 36 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 37 | // This Grunt plugin is part of the default asset pipeline in Sails, 38 | // so it's already been automatically loaded for you at this point. 39 | // 40 | // Of course, you can always remove this Grunt plugin altogether by 41 | // deleting this file. But check this out: you can also use your 42 | // _own_ custom version of this Grunt plugin. 43 | // 44 | // Here's how: 45 | // 46 | // 1. Install it as a local dependency of your Sails app: 47 | // ``` 48 | // $ npm install grunt-contrib-uglify --save-dev --save-exact 49 | // ``` 50 | // 51 | // 52 | // 2. Then uncomment the following code: 53 | // 54 | // ``` 55 | // // Load Grunt plugin from the node_modules/ folder. 56 | // grunt.loadNpmTasks('grunt-contrib-uglify'); 57 | // ``` 58 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 59 | } 60 | -------------------------------------------------------------------------------- /tasks/config/watch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/config/watch` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * Run predefined tasks whenever certain files are added, changed or deleted. 7 | * 8 | * For more information, see: 9 | * https://sailsjs.com/anatomy/tasks/config/watch.js 10 | * 11 | */ 12 | module.exports = function (grunt) { 13 | grunt.config.set('watch', { 14 | assets: { 15 | 16 | // Assets to watch: 17 | files: [ 18 | 'assets/**/*', 19 | 'tasks/pipeline.js', 20 | '!**/node_modules/**' 21 | ], 22 | 23 | // When assets are changed: 24 | tasks: [ 25 | 'syncAssets', 26 | 'linkAssets' 27 | ] 28 | } 29 | }) 30 | 31 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 32 | // This Grunt plugin is part of the default asset pipeline in Sails, 33 | // so it's already been automatically loaded for you at this point. 34 | // 35 | // Of course, you can always remove this Grunt plugin altogether by 36 | // deleting this file. But check this out: you can also use your 37 | // _own_ custom version of this Grunt plugin. 38 | // 39 | // Here's how: 40 | // 41 | // 1. Install it as a local dependency of your Sails app: 42 | // ``` 43 | // $ npm install grunt-contrib-watch --save-dev --save-exact 44 | // ``` 45 | // 46 | // 47 | // 2. Then uncomment the following code: 48 | // 49 | // ``` 50 | // // Load Grunt plugin from the node_modules/ folder. 51 | // grunt.loadNpmTasks('grunt-contrib-watch'); 52 | // ``` 53 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 54 | } 55 | -------------------------------------------------------------------------------- /tasks/register/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/build.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * This Grunt tasklist will be executed if you run `sails www` or 7 | * `grunt build` in a development environment. 8 | * 9 | * For more information see: 10 | * https://sailsjs.com/anatomy/tasks/register/build.js 11 | * 12 | */ 13 | module.exports = function (grunt) { 14 | grunt.registerTask('build', [ 15 | // 'polyfill:dev', //« uncomment to ALSO transpile during development (for broader browser compat.) 16 | 'compileAssets', 17 | // 'babel', //« uncomment to ALSO transpile during development (for broader browser compat.) 18 | 'linkAssetsBuild', 19 | 'clean:build', 20 | 'copy:build' 21 | ]) 22 | } 23 | -------------------------------------------------------------------------------- /tasks/register/buildProd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/buildProd.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * This Grunt tasklist will be executed instead of `build` if you 7 | * run `sails www` in a production environment, e.g.: 8 | * `NODE_ENV=production sails www` 9 | * 10 | * For more information see: 11 | * https://sailsjs.com/anatomy/tasks/register/build-prod.js 12 | * 13 | */ 14 | module.exports = function (grunt) { 15 | grunt.registerTask('buildProd', [ 16 | 'polyfill:prod', // « Remove this to skip transpilation in production (not recommended) 17 | 'compileAssets', 18 | 'babel', // « Remove this to skip transpilation in production (not recommended) 19 | 'concat', 20 | 'uglify', 21 | 'cssmin', 22 | 'hash', // « Cache-busting 23 | 'copy:beforeLinkBuildProd', // « For prettier URLs after cache-busting 24 | 'linkAssetsBuildProd', 25 | 'clean:build', 26 | 'copy:build', 27 | 'clean:afterBuildProd' 28 | ]) 29 | } 30 | -------------------------------------------------------------------------------- /tasks/register/compileAssets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/compileAssets.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/compile-assets.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('compileAssets', [ 12 | 'clean:dev', 13 | 'jst:dev', 14 | 'less:dev', 15 | 'copy:dev', 16 | 'coffee:dev' 17 | ]) 18 | } 19 | -------------------------------------------------------------------------------- /tasks/register/default.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/default.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * This is the default Grunt tasklist that will be executed if you 7 | * run `grunt` in the top level directory of your app. It is also 8 | * called automatically when you start Sails in development mode using 9 | * `sails lift` or `node app` in a development environment. 10 | * 11 | * For more information see: 12 | * https://sailsjs.com/anatomy/tasks/register/default.js 13 | * 14 | */ 15 | module.exports = function (grunt) { 16 | grunt.registerTask('default', [ 17 | // 'polyfill:dev', //« uncomment to ALSO transpile during development (for broader browser compat.) 18 | 'compileAssets', 19 | // 'babel', //« uncomment to ALSO transpile during development (for broader browser compat.) 20 | 'linkAssets', 21 | 'watch' 22 | ]) 23 | } 24 | -------------------------------------------------------------------------------- /tasks/register/linkAssets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/linkAssets.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/link-assets.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('linkAssets', [ 12 | 'sails-linker:devJs', 13 | 'sails-linker:devStyles', 14 | 'sails-linker:clientSideTemplates' 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /tasks/register/linkAssetsBuild.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/linkAssetsBuild.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/link-assets-build.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('linkAssetsBuild', [ 12 | 'sails-linker:devJsBuild', 13 | 'sails-linker:devStylesBuild', 14 | 'sails-linker:clientSideTemplatesBuild' 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /tasks/register/linkAssetsBuildProd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/linkAssetsBuildProd.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/link-assets-build-prod.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('linkAssetsBuildProd', [ 12 | 'sails-linker:prodJsBuild', 13 | 'sails-linker:prodStylesBuild', 14 | 'sails-linker:clientSideTemplatesBuild' 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /tasks/register/polyfill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/polyfill.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/polyfill.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('polyfill:prod', 'Add the polyfill file to the top of the list of files to concatenate', () => { 12 | grunt.config.set('concat.js.src', [require('sails-hook-grunt/accessible/babel-polyfill')].concat(grunt.config.get('concat.js.src'))) 13 | }) 14 | grunt.registerTask('polyfill:dev', 'Add the polyfill file to the top of the list of files to copy and link', () => { 15 | grunt.config.set('copy.dev.files', grunt.config.get('copy.dev.files').concat({ 16 | expand: true, 17 | cwd: require('path').dirname(require('sails-hook-grunt/accessible/babel-polyfill')), 18 | src: require('path').basename(require('sails-hook-grunt/accessible/babel-polyfill')), 19 | dest: '.tmp/public/polyfill' 20 | })) 21 | var devLinkFiles = grunt.config.get('sails-linker.devJs.files') 22 | grunt.config.set('sails-linker.devJs.files', Object.keys(devLinkFiles).reduce((linkerConfigSoFar, glob) => { 23 | linkerConfigSoFar[glob] = ['.tmp/public/polyfill/polyfill.min.js'].concat(devLinkFiles[glob]) 24 | return linkerConfigSoFar 25 | }, {})) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /tasks/register/prod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/prod.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * This Grunt tasklist will be executed instead of `default` when 7 | * your Sails app is lifted in a production environment (e.g. using 8 | * `NODE_ENV=production node app`). 9 | * 10 | * For more information see: 11 | * https://sailsjs.com/anatomy/tasks/register/prod.js 12 | * 13 | */ 14 | module.exports = function (grunt) { 15 | grunt.registerTask('prod', [ 16 | 'polyfill:prod', // « Remove this to skip transpilation in production (not recommended) 17 | 'compileAssets', 18 | 'babel', // « Remove this to skip transpilation in production (not recommended) 19 | 'concat', 20 | 'uglify', 21 | 'cssmin', 22 | 'sails-linker:prodJs', 23 | 'sails-linker:prodStyles', 24 | 'sails-linker:clientSideTemplates' 25 | ]) 26 | } 27 | -------------------------------------------------------------------------------- /tasks/register/syncAssets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `tasks/register/syncAssets.js` 3 | * 4 | * --------------------------------------------------------------- 5 | * 6 | * For more information see: 7 | * https://sailsjs.com/anatomy/tasks/register/sync-assets.js 8 | * 9 | */ 10 | module.exports = function (grunt) { 11 | grunt.registerTask('syncAssets', [ 12 | 'jst:dev', 13 | 'less:dev', 14 | 'sync:dev', 15 | 'coffee:dev' 16 | ]) 17 | } 18 | -------------------------------------------------------------------------------- /test/lifecycle.test.js: -------------------------------------------------------------------------------- 1 | var sails = require('sails') 2 | 3 | // Before running any tests... 4 | before(function (done) { 5 | // Increase the Mocha timeout so that Sails has enough time to lift, even if you have a bunch of assets. 6 | this.timeout(5000) 7 | 8 | sails.lift({ 9 | // Your sails app's configuration files will be loaded automatically, 10 | // but you can also specify any other special overrides here for testing purposes. 11 | 12 | // For example, we might want to skip the Grunt hook, 13 | // and disable all logs except errors and warnings: 14 | hooks: { grunt: false }, 15 | log: { level: 'warn' } 16 | 17 | }, function (err) { 18 | if (err) { return done(err) } 19 | 20 | // here you can load fixtures, etc. 21 | // (for example, you might want to create some records in the database) 22 | 23 | return done() 24 | }) 25 | }) 26 | 27 | // After all tests have finished... 28 | after(function (done) { 29 | // here you can clear fixtures, etc. 30 | // (e.g. you might want to destroy the records you created above) 31 | 32 | sails.lower(done) 33 | }) 34 | -------------------------------------------------------------------------------- /views/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐ 3 | // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤ 4 | // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘ 5 | // ┌─ ┌─┐┌─┐┬─┐ ┬┌┐┌┬ ┬┌┐┌┌─┐ ┌─┐┌─┐┬─┐┬┌─┐┌┬┐ ┌┬┐┌─┐┌─┐┌─┐ ─┐ 6 | // │ ├┤ │ │├┬┘ │││││ ││││├┤ └─┐│ ├┬┘│├─┘ │ │ ├─┤│ ┬└─┐ │ 7 | // └─ └ └─┘┴└─ ┴┘└┘┴─┘┴┘└┘└─┘ └─┘└─┘┴└─┴┴ ┴ ┴ ┴ ┴└─┘└─┘ ─┘ 8 | // > An .eslintrc configuration override for use in the `views/` directory. 9 | // 10 | // (This works just like assets/.eslintrc, with one minor addition) 11 | // 12 | // For more information see: 13 | // https://sailsjs.com/anatomy/views/.eslintrc 14 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 15 | "extends": [ 16 | "../assets/.eslintrc" 17 | ], 18 | "rules": { 19 | "eol-last": [0] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /views/layouts/layout.ejs: -------------------------------------------------------------------------------- 1 | <%- body %> 2 | -------------------------------------------------------------------------------- /views/pages/admin.ejs: -------------------------------------------------------------------------------- 1 | <%- partial('../../.tmp/public/admin.html') %> 2 | -------------------------------------------------------------------------------- /views/pages/app.ejs: -------------------------------------------------------------------------------- 1 | <%- partial('../../.tmp/public/index.html') %> 2 | -------------------------------------------------------------------------------- /views/pages/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | River of Ebooks 6 | 7 | 8 | 9 | 10 | 11 | <%- partial('../shared/header.html') %> 12 |
    13 |
    14 | <%- content %> 15 | 16 | <% if ((typeof feedItems !== "undefined") && feedItems.length) { %> 17 |

    Recently published ebooks

    18 |
      19 | <% for(const item of feedItems) { %> 20 |
    • 21 |
      22 |
      23 |

      <%= item.title %>

      24 |

      <%= item.author %> - <%= item.publisher %>

      25 |
      26 | <%= new Date(item.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }) %> 27 |
      28 |
      29 | <% for (const tag of JSON.parse(item.tags)) { %> 30 | <%= tag %> 31 | <% } %> 32 |
      33 |
    • 34 | <% } %> 35 |
    36 | <% } %> 37 |
    38 |
    39 | <%- partial('../shared/footer.html') %> 40 | 41 | 42 | -------------------------------------------------------------------------------- /views/pages/login.ejs: -------------------------------------------------------------------------------- 1 | <%- partial('../../.tmp/public/login.html') %> 2 | -------------------------------------------------------------------------------- /views/shared/footer.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /views/shared/header.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 10 |
    11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | const webpack = require('webpack') 4 | const path = require('path') 5 | 6 | module.exports = (env, argv) => { 7 | const mode = argv.mode || 'development' 8 | 9 | return { 10 | mode: mode || 'development', 11 | entry: { 12 | login: './assets/js/login.js', 13 | index: './assets/js/index.js', 14 | admin: './assets/js/admin.js' 15 | }, 16 | output: { 17 | path: path.join(__dirname, '/.tmp/public'), 18 | filename: '[name].bundle.js', 19 | publicPath: '/' 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | use: 'babel-loader', 25 | test: /\.jsx?$/, 26 | exclude: /node_modules/ 27 | }, 28 | { 29 | test: /\.scss$/, 30 | use: [ 31 | mode !== 'production' ? 'style-loader' : MiniCssExtractPlugin.loader, 32 | 'css-loader', 33 | 'sass-loader' 34 | ] 35 | } 36 | ] 37 | }, 38 | plugins: [ 39 | new HtmlWebpackPlugin({ 40 | template: 'assets/templates/login.html', 41 | // links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/login.css' }] : [], 42 | filename: path.join(__dirname, '/.tmp/public/login.html'), 43 | chunks: ['login'] 44 | }), 45 | new HtmlWebpackPlugin({ 46 | template: 'assets/templates/index.html', 47 | // links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/index.css' }] : [], 48 | filename: path.join(__dirname, '/.tmp/public/index.html'), 49 | chunks: ['index'] 50 | }), 51 | new HtmlWebpackPlugin({ 52 | template: 'assets/templates/admin.html', 53 | // links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/admin.css' }] : [], 54 | filename: path.join(__dirname, '/.tmp/public/admin.html'), 55 | chunks: ['admin'] 56 | }), 57 | new MiniCssExtractPlugin({ 58 | filename: '[name].css' 59 | }), 60 | new webpack.DefinePlugin({ 61 | 'process.env.NODE_ENV': JSON.stringify(mode) 62 | }) 63 | ], 64 | devServer: { 65 | historyApiFallback: true, 66 | disableHostCheck: true, 67 | port: 8080 68 | } 69 | } 70 | } 71 | --------------------------------------------------------------------------------