From 66580eec6c68b5bda3eaf796183d8f8b4fba0a78 Mon Sep 17 00:00:00 2001 From: wrobbins Date: Fri, 15 May 2020 12:19:12 -0500 Subject: [PATCH 1/4] feat: allow metadata in POST to /api/v1/books - added ability to include the metadata property in the books POST endpoint to set things like coverPath - add .nvmrc for node v13.14.0 --- .nvmrc | 1 + lib/book.js | 1 + tests/book-test.js | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b1bd38b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +13 diff --git a/lib/book.js b/lib/book.js index 11df768..d488ff9 100644 --- a/lib/book.js +++ b/lib/book.js @@ -115,6 +115,7 @@ class Book { title: attrs.title, description: attrs.description, author: attrs.author, + ...attrs.metadata, }, sections ); diff --git a/tests/book-test.js b/tests/book-test.js index 8dff183..5925ea0 100644 --- a/tests/book-test.js +++ b/tests/book-test.js @@ -171,7 +171,7 @@ describe('Book', () => { }); }); - it('accepts valid metadata', () => { + it('accepts valid named metadata', () => { const validMetadataKeys = ['title', 'author', 'description']; const jsonBook = Book.fromJSON(reqBody); @@ -181,6 +181,22 @@ describe('Book', () => { expect(metadata[key]).toEqual(reqBody[key]); }); }); + + it('accepts valid unnamed metadata', () => { + const coverPath = 'https://via.placeholder.com/816x1056.jpg?text=CoverPage'; + const extendedReqBody = { + metadata: { + coverPath, + }, + ...reqBody, + }; + + const jsonBook = Book.fromJSON(extendedReqBody); + const metadata = jsonBook.getMetadata(); + + expect(metadata.coverPath).toEqual(coverPath); + expect(jsonBook.getCoverPath()).toEqual(coverPath); + }); }); describe('.sanitizeTitle', () => { From 08effc440cbd91477ad022e7bfef45f72c657712 Mon Sep 17 00:00:00 2001 From: wrobbins Date: Mon, 25 May 2020 17:12:16 -0500 Subject: [PATCH 2/4] Add more fields from nodepub - new: documentation for the shape of the JSON that .fromJSON takes - new: 400 error invalid metadata key in ctor of book - new: fields now supported in POST /api/v1/books: - genre - language - series - sequence - tags - publisher - genre --- .gitignore | 3 +- lib/app-errors.js | 7 +++++ lib/book.js | 69 ++++++++++++++++++++++++++++++++++++++++------ lib/constants.js | 17 ++++++++++++ tests/book-test.js | 36 ++++++++++++------------ 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 6a65643..83aa9dc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ ebooks/** *.log hidden secrets/*.env -envs/*.env \ No newline at end of file +envs/*.env +.vscode/ diff --git a/lib/app-errors.js b/lib/app-errors.js index c9dbf13..70f2133 100644 --- a/lib/app-errors.js +++ b/lib/app-errors.js @@ -33,6 +33,13 @@ class AppErrors { return Object.keys(patterns).find((key) => RegExp(patterns[key]).test(error.message)); } + + static invalidBookKey(keyName) { + return { + status: '400', + message: `Request contained an invalid property '${keyName}'`, + }; + } } AppErrors.api = { diff --git a/lib/book.js b/lib/book.js index d488ff9..c8df1fe 100644 --- a/lib/book.js +++ b/lib/book.js @@ -1,11 +1,13 @@ 'use strict'; + const fs = require('fs'); const shortid = require('shortid'); const sanitizeHtml = require('sanitize-html'); const Url = require('url'); +const { VALID_BOOK_METADATA_KEYS } = require('./constants'); const AppErrors = require('./app-errors'); const Config = require('./config'); const Utilities = require('./utilities'); @@ -40,6 +42,16 @@ class Book { return !!((section.title && section.content) || section.url); } + static isValidMetadataKey(key) { + return VALID_BOOK_METADATA_KEYS.includes(key); + } + + static assertIsValidMetadataKey(key) { + if (!Book.isValidMetadataKey(key)) { + throw AppErrors.invalidBookKey(key); + } + } + static fallbackTitle(section) { let fallbackTitle; const titleMatches = section.html.match(/(.*?)<\/title>/i); @@ -99,6 +111,33 @@ class Book { return tableOfContents.join('\n'); } + /** + * A book + * @typedef {Object} Book + * @property {string} author - the authof of the book (default: EpubPress) + * @property {string} coverPath - (optional) http(s) link to an + * image for the cover of the book (816x1056) + * @property {string} description - description of book + * (default: 'Built using https://epub.press') + * @property {string} genre - the genre of the book (default: Unknown) + * @property {string} language - the languages of the book (default: en) + * @property {string} title - title of book + * @property {number} published - publish Date (default: today) + * @property {string} publisher - publisher (deafult: https://epub.press) + * @property {Object[]} sections - collection of sections for the book. + * A section include a title and (html) content OR a url. Mutually exclusive with urls + * @property {string} series - the series - used in Calibre (default: '') + * @property {number} sequence - the sequence - used in Calibre (default: '') + * @property {string} tags - (optional) a CSV list of strings that become + * tags/subject for the book (default: empty) + * @property {string[]} urls - collection of sites to transform and add as sections + * to the book. Mutually exclusive with section + */ + + /** + * Create a book from json + * @param {Book} book - The {@link Book} to be created + */ static fromJSON(json) { let reqBody = json; if (typeof reqBody === 'string') { @@ -110,15 +149,7 @@ class Book { sections = attrs.urls.map((url) => ({ url, html: null })); } - return new Book( - { - title: attrs.title, - description: attrs.description, - author: attrs.author, - ...attrs.metadata, - }, - sections - ); + return new Book(attrs, sections); } constructor(metadata, sections) { @@ -129,6 +160,11 @@ class Book { this._metadata = { id, title: `EpubPress - ${date}`, ...Book.DEFAULT_METADATA }; Object.keys(metadata || {}).forEach((metaKey) => { if (metadata[metaKey]) { + // these properties can come in with metadata, but are not the same + // metadata that is passed to nodepub + if (metaKey !== 'sections' && metaKey !== 'urls') { + Book.assertIsValidMetadataKey(metaKey); + } this._metadata[metaKey] = Book.replaceCharacters(sanitizeHtml(metadata[metaKey])); } }); @@ -136,6 +172,10 @@ class Book { this._sections = sections || []; } + /** + * @returns metadata used to generate the book in the ebpub library, nodepub + * @see https://github.com/kcartlidge/nodepub#creating-your-content for details + */ getMetadata() { return this._metadata; } @@ -152,10 +192,17 @@ class Book { return `${this.getPath()}.mobi`; } + /** + * @returns path to cover image of the book + */ getCoverPath() { return this.getMetadata().coverPath || Book.DEFAULT_COVER_PATH; } + getTags() { + return this.getMetadata().tags || ''; + } + setCoverPath(path) { this._metadata.coverPath = path; } @@ -287,7 +334,11 @@ Book.DEFAULT_METADATA = { genre: 'Unknown', language: 'en', published: new Date().getFullYear(), + publisher: 'https://epub.press', + series: '', + sequence: undefined, images: [], + tags: '', }; module.exports = Book; diff --git a/lib/constants.js b/lib/constants.js index 11399ff..047060f 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -46,4 +46,21 @@ Constants.INVALID_ATTRIBUTES = [ 'aria-label', ]; +Constants.VALID_BOOK_METADATA_KEYS = [ + 'author', + 'coverPath', + 'description', + 'genre', + 'id', + 'images', + 'language', + 'published', + 'publisher', + 'sequence', + 'series', + 'tags', + 'title', +]; + + module.exports = Constants; diff --git a/tests/book-test.js b/tests/book-test.js index 5925ea0..93337e4 100644 --- a/tests/book-test.js +++ b/tests/book-test.js @@ -5,6 +5,7 @@ const TestHelpers = require('./helpers'); const Book = require('../lib/book'); const Utilities = require('../lib/utilities'); +const { VALID_BOOK_METADATA_KEYS } = require('../lib/constants'); const bookMetadata = { title: 'Test Book', @@ -171,31 +172,30 @@ describe('Book', () => { }); }); - it('accepts valid named metadata', () => { - const validMetadataKeys = ['title', 'author', 'description']; - - const jsonBook = Book.fromJSON(reqBody); + it('accepts valid metadata', () => { + const coverPath = 'https://via.placeholder.com/816x1056.jpg?text=Cover'; + const tags = 'One, Two, Three'; + const reqBodyClone = { coverPath, tags, ...reqBody }; + const jsonBook = Book.fromJSON(reqBodyClone); const metadata = jsonBook.getMetadata(); - validMetadataKeys.forEach((key) => { - expect(metadata[key]).toEqual(reqBody[key]); + VALID_BOOK_METADATA_KEYS.forEach((key) => { + if (reqBodyClone[key]) { + expect(metadata[key]).toEqual(reqBodyClone[key]); + } }); + + expect(jsonBook.getCoverPath()).toEqual(coverPath); + expect(jsonBook.getTags()).toEqual(tags); }); - it('accepts valid unnamed metadata', () => { - const coverPath = 'https://via.placeholder.com/816x1056.jpg?text=CoverPage'; - const extendedReqBody = { - metadata: { - coverPath, - }, - ...reqBody, + it('throws invalid property error', () => { + const reqBodyWithInvalidProperty = { 'bad-property': 'bad-value', ...reqBody }; + const act = () => { + Book.fromJSON(reqBodyWithInvalidProperty); }; - const jsonBook = Book.fromJSON(extendedReqBody); - const metadata = jsonBook.getMetadata(); - - expect(metadata.coverPath).toEqual(coverPath); - expect(jsonBook.getCoverPath()).toEqual(coverPath); + expect(act).toThrow('invalid property \'bad-property\''); }); }); From 97c2f410850f903e1eb4926f72359fe0213703d9 Mon Sep 17 00:00:00 2001 From: wrobbins <no> Date: Mon, 25 May 2020 17:20:58 -0500 Subject: [PATCH 3/4] lint --fix --- lib/book.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/book.js b/lib/book.js index c8df1fe..8982c83 100644 --- a/lib/book.js +++ b/lib/book.js @@ -160,7 +160,7 @@ class Book { this._metadata = { id, title: `EpubPress - ${date}`, ...Book.DEFAULT_METADATA }; Object.keys(metadata || {}).forEach((metaKey) => { if (metadata[metaKey]) { - // these properties can come in with metadata, but are not the same + // these properties can come in with metadata, but are not the same // metadata that is passed to nodepub if (metaKey !== 'sections' && metaKey !== 'urls') { Book.assertIsValidMetadataKey(metaKey); From 2edb8dece12c357d1b9bac4e6dca4db7a047ad14 Mon Sep 17 00:00:00 2001 From: wrobbins <no> Date: Mon, 25 May 2020 17:42:31 -0500 Subject: [PATCH 4/4] prevent coverPath from being anything but deafult, http, https --- lib/app-errors.js | 1 + lib/book.js | 6 ++++++ tests/book-test.js | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/lib/app-errors.js b/lib/app-errors.js index 70f2133..69b65a4 100644 --- a/lib/app-errors.js +++ b/lib/app-errors.js @@ -59,6 +59,7 @@ AppErrors.api = { message: 'Request exceeds size limit. Try less items.', }, NO_SECTIONS_SPECIFIED: { status: '400', message: 'No sections provided.' }, + COVER_PATH_INVALID: { status: '400', message: 'coverPath must be http or https' }, NOT_FOUND: { status: '404', message: 'Not found.' }, MALFORMED_REQUEST: { status: '400', diff --git a/lib/book.js b/lib/book.js index 8982c83..4502412 100644 --- a/lib/book.js +++ b/lib/book.js @@ -169,6 +169,12 @@ class Book { } }); + const coverPath = this.getCoverPath(); + + if (coverPath !== Book.DEFAULT_COVER_PATH && !coverPath.startsWith('http')) { + throw AppErrors.api.COVER_PATH_INVALID; + } + this._sections = sections || []; } diff --git a/tests/book-test.js b/tests/book-test.js index 93337e4..acdc740 100644 --- a/tests/book-test.js +++ b/tests/book-test.js @@ -43,6 +43,27 @@ describe('Book', () => { expect(sections[0].url).toEqual('http://google.com'); expect(sections[0].html).toEqual('<html></html>'); }); + + it('accepts cover path with http', () => { + const metadata = { coverPath: 'http://localhost', ...bookMetadata }; + const bookWithCover = new Book(metadata); + expect(bookWithCover.getCoverPath()).toEqual('http://localhost'); + }); + + it('accepts cover path with https', () => { + const metadata = { coverPath: 'https://localhost', ...bookMetadata }; + const bookWithCover = new Book(metadata); + expect(bookWithCover.getCoverPath()).toEqual('https://localhost'); + }); + + it('rejects cover path that is not http(s)', () => { + const metadata = { coverPath: '/etc/passwd', ...bookMetadata }; + const act = () => { + // eslint-disable-next-line + new Book(metadata); + }; + expect(act).toThrow('coverPath must be http or https'); + }); }); describe('#getId', () => {