diff --git a/.circleci/config.yml b/.circleci/config.yml index 13d339892..3c48e7d34 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,7 +27,7 @@ jobs: # fallback to using the latest cache if no exact match is found - v1-dependencies- - - node/install-npm + - run: sudo npm install -g npm@8.10.0 - node/install-packages: app-dir: ~/homebrewery cache-path: node_modules @@ -55,13 +55,19 @@ jobs: at: . # run tests! - - run: + - run: + name: Test - API Unit Tests + command: npm run test:api-unit + - run: name: Test - Basic command: npm run test:basic - - run: + - run: + name: Test - Coverage + command: npm run test:coverage + - run: name: Test - Mustache Spans command: npm run test:mustache-span - - run: + - run: name: Test - Routes command: npm run test:route @@ -71,4 +77,4 @@ workflows: - build - test: requires: - - build \ No newline at end of file + - build diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..28da2ef34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: /r/Homebrewery Subreddit + url: https://www.reddit.com/r/homebrewery + about: The Homebrewery community on Reddit! + - name: Discord of Many Things + url: https://discord.gg/domt + about: "Join the conversation in the #formatting channel on DoMT!" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..b87b267e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,17 @@ +name: Feature Request +description: Have an idea to improve the Homebrewery? Let us know! +labels: ["feature request"] +body: + - type: markdown + attributes: + value: "We'd love to hear your idea! Please be sure to [search the current Issues](https://github.com/naturalcrit/homebrewery/issues) for any duplicate requests." + - type: textarea + id: user-request + attributes: + label: "Your idea:" + description: The best feature requests provide an explanation of the current issue and then an explanation of how it could be improved. Screenshots/images can be pasted right in as well! + validations: + required: true + - type: markdown + attributes: + value: "Please be sure to search for any close matches to your request in the GitHub Issues tracker before opening a new request, thanks!" diff --git a/.github/ISSUE_TEMPLATE/general_issue.yml b/.github/ISSUE_TEMPLATE/general_issue.yml new file mode 100644 index 000000000..18c19254e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general_issue.yml @@ -0,0 +1,55 @@ +name: General Issue +description: Report an issue unrelated to Saving +body: + - type: markdown + attributes: + value: Please include as much information as possible. + - type: dropdown + id: renderer + attributes: + label: Renderer + description: Which renderer does this issue occur on? If you are unsure, you can check the renderer in the Properties Editor (click the "i" in the Snippet Menu bar above the editor). + options: + - v3 + - Legacy + - Both + validations: + required: true + - type: dropdown + id: browser + attributes: + label: Browser + description: Which browser were you using when the issue occurred? + options: + - Chrome + - Firefox + - Edge + - Safari + - other + validations: + required: true + - type: dropdown + id: operating-system + attributes: + label: Operating System + description: Which OS were you using when the issue occurred? + options: + - Windows + - MacOS + - Linux + - other + validations: + required: true + - type: textarea + id: user-description + attributes: + label: "What happened?" + description: Please include any steps you took leading up to the issue and if you can reproduce it. Let us know what you expected to happen, and what did happen. + validations: + required: true + - type: textarea + id: code + attributes: + label: Code + description: Paste in any relevant code snippet below. + render: gfm \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/save_issue.yml b/.github/ISSUE_TEMPLATE/save_issue.yml new file mode 100644 index 000000000..c08f485ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/save_issue.yml @@ -0,0 +1,26 @@ +name: Saving Issue +description: Report an issue Saving +labels: ["Saving"] +body: + - type: markdown + attributes: + value: | + Woops, sorry there was an issue Saving. Please add any detail you can to this report and check back soon! + - type: textarea + id: error-code + attributes: + label: Error Code + render: shell + - type: textarea + id: user-description + attributes: + label: "Your description of what happened:" + validations: + required: true + - type: markdown + attributes: + value: | + Thanks for the report. Here are some steps that may help in the meantime: + 1. Refreshing your Google credentials in Homebrewery by signing out, and back in (they expire after one year). + 2. Waiting a few minutes and trying again - sometimes there is just a momentary blip in the server. + 3. Check the Issues in Github or the /r/homebrewery subreddit to see if others are experiencing the same issue. diff --git a/.gitignore b/.gitignore index 2f081e2dc..150d81008 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ config/docker.* todo.md startDB.bat startMViewer.bat +.vscode + +coverage diff --git a/README.md b/README.md index 35f0150d1..df7f41503 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,29 @@ below. First, install three programs that The Homebrewery requires to run and retrieve updates: -1. install [node](https://nodejs.org/en/) +1. install [node](https://nodejs.org/en/), version v16 or higher. 1. install [mongodb](https://www.mongodb.com/try/download/community) (Community version) For the easiest installation, follow these steps: 1. In the installer, uncheck the option to run as a service. 1. You can install MongoDB Compass if you want a GUI to view your database documents. + 1. If you install any version over 6.0, you will have to install [MongoDB Shell](https://www.mongodb.com/try/download/shell). 1. Go to the C:\ drive and create a folder called "data". 1. Inside the "data" folder, create a new folder called "db". - 1. Open a command prompt or other terminal and navigate to your MongoDB install folder (C:\Program Files\Mongo\Server\4.4\bin). + 1. Open a command prompt or other terminal and navigate to your MongoDB install folder (C:\Program Files\Mongo\Server\6.0\bin). 1. In the command prompt, run "mongod", which will start up your local database server. 1. While MongoD is running, open a second command prompt and navigate to the MongoDB install folder. - 1. In the second command prompt, run "mongo", which allows you to edit the database. - 1. Type `use homebrewery` to create The Homebrewery database. You should see `switched to db homebrewery`. - 1. Type `db.brews.insert({"title":"test"})` to create a blank document. You should see `WriteResult({ "nInserted" : 1 })`. 1. Search in Windows for "Advanced system settings" and open it. 1. Click "Environment variables", find the "path" variable, and double-click to open it. 1. Click "New" and paste in the path to the MongoDB "bin" folder. 1. Click "OK" three times to close all the windows. + 1. In the second command prompt, run "mongo", which allows you to edit the database. + 1. Type `use homebrewery` to create The Homebrewery database. You should see `switched to db homebrewery`. + 1. Type `db.brews.insertOne({"title":"test"})` to create a blank document. You should see `{ +acknowledged: true, +insertedId: ObjectId("63c2fce9e5ac5a94fe2410cf") +}` + 1. install [git](https://git-scm.com/downloads) (select the option that allows Git to run from the command prompt). Checkout the repo ([documentation][github-clone-repo-docs-url]): @@ -51,11 +56,19 @@ git clone https://github.com/naturalcrit/homebrewery.git Second, you will need to add the environment variable `NODE_ENV=local` to allow the project to run locally. -You can set this temporarily in your shell of choice: +You can set this **temporarily** (until you close the terminal) in your shell of choice with admin privileges: * Windows Powershell: `$env:NODE_ENV="local"` * Windows CMD: `set NODE_ENV=local` * Linux / macOS: `export NODE_ENV=local` +If you want to add this variable **permanently** the steps are as follows: + 1. Search in Windows for "Advanced system settings" and open it. + 1. Click "Environment variables". + 1. In System Variables, click "New" + 1. Click "New" and write `NODE_ENV` as a name and `local` as the value. + 1. Click "OK" three times to close all the windows. + This can be undone at any time if needed. + Third, you will need to install the Node dependencies, compile the app, and run it using the two commands: @@ -65,6 +78,13 @@ it using the two commands: You should now be able to go to [http://localhost:8000](http://localhost:8000) in your browser and use The Homebrewery offline. +If you had any issue at all, here are some links that may be useful: +- [Course](https://learn.mongodb.com/courses/m103-basic-cluster-administration) on cluster administration, useful for beginners +- [Mongo community forums](https://www.mongodb.com/community/forums/) +- Useful Stack Overflow links for your most probable errors: [1](https://stackoverflow.com/questions/44962540/mongod-and-mongo-commands-not-working-on-windows-10), [2](https://stackoverflow.com/questions/15053893/mongo-command-not-recognized-when-trying-to-connect-to-a-mongodb-server/41507803#41507803), [3](https://stackoverflow.com/questions/51224959/mongo-is-not-recognized-as-an-internal-or-external-command-operable-program-o) + +If you still have problems, post in [Our Subreddit](https://www.reddit.com/r/homebrewery/) and we will help you. + ### Running the application via Docker Please see the docs here: [README.DOCKER.md](./README.DOCKER.md) diff --git a/changelog.md b/changelog.md index 9fed601f8..0260a1f44 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,23 @@ ```css +.beta { + color : white; + padding : 4px 6px; + line-height : 1em; + background : grey; + border-radius : 12px; + font-family : monospace; + font-size : 10px; + font-weight : 800; + margin-top : -5px; + margin-bottom : -5px; +} + +.fac { + height: 1em; + line-height: 2em; + margin-bottom: -0.05cm +} + h5 { font-size: .35cm !important; } @@ -7,6 +26,11 @@ h5 { margin-left: 0px; } +.page .taskList { + display:block; + break-inside:auto; +} + .taskList li input { list-style-type : none; margin-left : -0.52cm; @@ -14,6 +38,11 @@ h5 { filter: brightness(1.1) drop-shadow(1px 2px 1px #222); } +.taskList ul { + margin-bottom: 0px; + margin-top: 0px; +} + .taskList li input[checked] { filter: sepia(100%) hue-rotate(60deg) saturate(3.5) contrast(4) brightness(1.1) drop-shadow(1px 2px 1px #222); } @@ -30,15 +59,367 @@ pre { margin-top : 0.1cm; } +.page ul + h5 { + margin-top: 0.25cm; +} + +.page p + h5 { + margin-top: 0.25cm; +} + .page .openSans { font-family: 'Open Sans'; font-size: 0.9em; } + +.page { + padding-bottom: 1.5cm; +} ``` ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). + +### Tuesday 28/02/2023 - v3.7.0 +{{taskList + +{{note +**NOTE:** Some new snippets will now show a {{beta BETA}} tag. Feel free to use them, but be aware we may change how they work depending on your feedback. +}} + +##### Calculuschild + +* [x] New {{openSans **IMAGES → WATERCOLOR EDGE** {{fac,mask-edge}} }} and {{openSans **WATERCOLOR CORNER** {{fac,mask-corner}} }} snippets for V3, which adds a stylish watercolor texture to the edge of your images! (Thanks to /u/flamableconcrete on Reddit for providing these image masks!) + +* [x] Fix site not displaying on iOS devices + +##### 5e-Cleric + +* [x] New {{openSans **PHB → COVER PAGE** {{fac,book-front-cover}} }} snippet for V3, which adds a stylish coverpage to your brew! (Thanks to /u/Kaiburr_Kath-Hound on Reddit for providing some of these resources!) + +##### MichielDeMey (new contribuor!) + +* [x] Fix typo in testing scripts +* [x] Fix "mug" image not using HTTPS + +Fixes issues [#2687](https://github.com/naturalcrit/homebrewery/issues/2687) +}} + +### Saturday 18/02/2023 - v3.6.1 +{{taskList +##### G-Ambatte + +* [x] Fix users not being removed from Authors list correctly + +Fixes issues [#2674](https://github.com/naturalcrit/homebrewery/issues/2674) +}} + + +### Monday 23/01/2023 - v3.6.0 +{{taskList +##### calculuschild + +* [x] Fix Google Drive brews sometimes duplicating + +Fixes issues [#2603](https://github.com/naturalcrit/homebrewery/issues/2603) + +##### Jeddai + +* [x] Add unit tests with full coverage for the Homebrewery API + +* [x] Add message to refresh the browser if the user is missing an update to the Homebrewery + +Fixes issues [#2583](https://github.com/naturalcrit/homebrewery/issues/2583) + +##### G-Ambatte + +* [x] Auto-compile Themes CSS on development server + +##### 5e-Cleric + +* [x] Fix cloned brews inheriting the parent view count +}} + +\column + +### Friday 23/12/2022 - v3.5.0 +{{taskList + +##### Jeddai + +* [x] Only brew owners or invited authors can edit a brew + + - Visiting an `/edit` page of a brew that does not list you as an author will result in an error page. Authors can be added to any brew by opening its {{fa,fa-info-circle}} **Properties** menu and typing the author's username (case-sensitive) into the **Invited Authors** bubble. + - Warn user if a newer brew version has been saved on another device + +Fixes issues [#1987](https://github.com/naturalcrit/homebrewery/issues/1987) +}} + +\page + +### Saturday 10/12/2022 - v3.4.2 +{{taskList + +##### Jeddai + +* [x] Fix broken tags editor + +* [x] Reduce server load to fix some saving issues + +Fixes issues [#2322](https://github.com/naturalcrit/homebrewery/issues/2322) + +##### G-Ambatte + +* [x] Account page help link for Google Drive errors + +Fixes issues [#2520](https://github.com/naturalcrit/homebrewery/issues/2520) +}} + +### Monday 05/12/2022 - v3.4.1 +{{taskList + +##### G-Ambatte + +* [x] Fix Account page incorrect last login time + +Fixes issues [#2521](https://github.com/naturalcrit/homebrewery/issues/2521) + +##### Gazook + +* [x] Fix crashing on iOS and Safari browsers + +Fixes issues [#2531](https://github.com/naturalcrit/homebrewery/issues/2531) +}} + +### Monday 28/11/2022 - v3.4.0 +{{taskList + +##### G-Ambatte + +* [x] Fix for Chrome v108 handling of page size + +Fixes issues [#2445](https://github.com/naturalcrit/homebrewery/issues/2445), [#2516](https://github.com/naturalcrit/homebrewery/issues/2516) + +* [x] New account page with some user info, at {{openSans **USERNAME {{fa,fa-user}} → ACCOUNT {{fa,fa-user}}**}} + +Fixes issues [#2049](https://github.com/naturalcrit/homebrewery/issues/2049), [#2043](https://github.com/naturalcrit/homebrewery/issues/2043) + +* [x] Fix "Published/Private Brews" buttons on userpage + +Fixes issues [#2449](https://github.com/naturalcrit/homebrewery/issues/2449) + +##### Gazook + +* [x] Make autosave default on for new users + +* [x] Added link to our FAQ at {{openSans **NEED HELP? {{fa,fa-question-circle}} → FAQ {{fa,fa-question-circle}}**}} + +* [x] Fix curly blocks freezing with long property lists + +Fixes issues [#2393](https://github.com/naturalcrit/homebrewery/issues/2393) + +* [x] Items can now be removed from {{openSans **RECENT BREWS** {{fas,fa-history}} }} + +Fixes issues [#1918](https://github.com/naturalcrit/homebrewery/issues/1918) + +* [x] Curly injector syntax `{blue}` highlighting in editor + +Fixes issues [#1670](https://github.com/naturalcrit/homebrewery/issues/1670) + +}} + +### Thursday 28/10/2022 - v3.3.1 +{{taskList + +##### Calculuschild + +* [x] Fixes to several broken CSS styles from v3.3.0 + +Fixes issues [#2468](https://github.com/naturalcrit/homebrewery/issues/2468) + +##### Jeddai + +* [x] Reduce size of thumbnails on social media links + +}} + +### Friday 19/10/2022 - v3.3.0 +{{taskList + +##### Calculuschild + +* [x] Fix for tables broken by Chrome v106 + + +##### G-Ambatte: + +* [x] Fix Table of Contents broken by Chrome v106 + +Fixes issues [#2437](https://github.com/naturalcrit/homebrewery/issues/2437) + +* [x] Show brew thumbnails on user page + +Fixes issues [#2331](https://github.com/naturalcrit/homebrewery/issues/2331) + +* [x] Allow longer URLs for brew thumbnails + +Fixes issues [#2351](https://github.com/naturalcrit/homebrewery/issues/2351) + +* [x] Code no longer unfolds when inserting a snippet + +Fixes issues [#2135](https://github.com/naturalcrit/homebrewery/issues/2135) + +* [x] Fix brew settings being lost on first save + +Fixes issues [#2427](https://github.com/naturalcrit/homebrewery/issues/2427) + +##### Gazook: + +* [x] Several updates to bug reporting and error popups + +Fixes issues [#2376](https://github.com/naturalcrit/homebrewery/issues/2376) + +* [x] Fixes to userpage search bar + +Fixes issues [#1675](https://github.com/naturalcrit/homebrewery/issues/1675), [#2353](https://github.com/naturalcrit/homebrewery/issues/2353) + +* [x] Renderer *(legacy / V3)* now shown next to page # + +Fixes issues [#1928](https://github.com/naturalcrit/homebrewery/issues/1928) + +* [x] Prevent text selection when moving divider bar + +Fixes issues [#1632](https://github.com/naturalcrit/homebrewery/issues/1632) + +* [x] Tweak Monster Stat Block coloring + +Fixes issues [#2123](https://github.com/naturalcrit/homebrewery/issues/2123) + +* [x] Added dropdown button to toggle autosave + +Fixes issues [#1546](https://github.com/naturalcrit/homebrewery/issues/1546) + +}} + + +### Friday 08/09/2022 - v3.2.2 +{{taskList + +##### Jeddai: + +* [x] Fix brews not deleting from User page when removed from Google Drive externally. + + Fixes issues: [#2325](https://github.com/naturalcrit/homebrewery/issues/2325) + +##### G-Ambatte: + +* [x] Brew Tags are now searchable on the User page + +Fixes issues [#2317](https://github.com/naturalcrit/homebrewery/issues/2317), [#2319](https://github.com/naturalcrit/homebrewery/issues/2319), [#2334](https://github.com/naturalcrit/homebrewery/issues/2334) + +* [x] Several tweaks to the User page + + Fixes issues: [#1797](https://github.com/naturalcrit/homebrewery/issues/1797), [#2315](https://github.com/naturalcrit/homebrewery/issues/2315), [#2326](https://github.com/naturalcrit/homebrewery/issues/2326), [#2328](https://github.com/naturalcrit/homebrewery/issues/2328) +}} + + + +\page + +### Wednesday 31/08/2022 - v3.2.1 +{{taskList + +##### Calculuschild + +* [x] Reference Links should now work inside tables + + Fixes issues: [#2307](https://github.com/naturalcrit/homebrewery/issues/2307) + +##### Jeddai: + +* [x] Fix printing from `/new` not working + + Fixes issues: [#1789](https://github.com/naturalcrit/homebrewery/issues/1789), [#1806](https://github.com/naturalcrit/homebrewery/issues/1806) + +* [x] Fix broken snippet buttons on `/new` + + Fixes issues: [#2311](https://github.com/naturalcrit/homebrewery/issues/2311) + +##### G-Ambatte: + +* [x] Several small tweaks to the User page + + Fixes issues: [#2301](https://github.com/naturalcrit/homebrewery/issues/2301), [#2303](https://github.com/naturalcrit/homebrewery/issues/2303), [#2121](https://github.com/naturalcrit/homebrewery/issues/2121) +}} + +### Saturday 27/08/2022 - v3.2.0 +{{taskList + +##### Calculuschild + +* [x] The V3 renderer is now the default for new brews. + +* [x] Small tweaks to the spacing around the `classTable` style + +##### Jeddai: + +* [x] Brew transfers between Homebrewery and Google Drive now keep the same share and edit links! Metadata is now also kept across transfers. + + Fixes issues: [#1838](https://github.com/naturalcrit/homebrewery/issues/1838) + +* [x] Brews can now be labeled with tags; these will be searchable on the My Brews page in a future update. + + Fixes issues: [#758](https://github.com/naturalcrit/homebrewery/issues/758) + +##### Jlgraves: + +* [x] Small tweaks to the `ClassFeature` snippet + + Fixes issues: [#2215](https://github.com/naturalcrit/homebrewery/issues/2215) +}} + + +### Thursday 09/06/2022 - v3.1.1 +{{taskList + +##### Calculuschild: + +* [x] Fixed class table decorations appearing on top of the table in PDF output. + + Fixes issues: [#1784](https://github.com/naturalcrit/homebrewery/issues/1784) + +* [x] Fix bottom decoration on half class tables disappearing when the table is too short. + + Fixes issues: [#2202](https://github.com/naturalcrit/homebrewery/issues/2202) +}} + +### Monday 06/06/2022 - v3.1.0 +{{taskList + +##### G-Ambatte: + +* [x] "Jump to Preview/Editor" buttons added to the divider bar. Easily sync between the editor and preview panels! + + Fixes issues: [#1756](https://github.com/naturalcrit/homebrewery/issues/1756) + +* [x] Speedups to the user page for users with large and/or many brews. + + Fixes issues: [#2147](https://github.com/naturalcrit/homebrewery/issues/2147) + +* [x] Search text on the user page is saved to the URL for easy bookmarking in your browser + + Fixes issues: [#1858](https://github.com/naturalcrit/homebrewery/issues/1858) + +* [x] Added easy login system for offline installs. + + Fixes issues: [#269](https://github.com/naturalcrit/homebrewery/issues/269) + +* [x] New **THUMBNAIL** option in the {{fa,fa-info-circle}} **Properties** menu. This image will show up in social media links. + + Fixes issues: [#820](https://github.com/naturalcrit/homebrewery/issues/820) +}} + ### Wednesday 27/03/2022 - v3.0.8 {{taskList * [x] Style updates to user page. diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 2da7123cc..a41e01228 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -1,3 +1,4 @@ +/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ require('./brewRenderer.less'); const React = require('react'); const createClass = require('create-react-class'); @@ -13,6 +14,8 @@ const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx'); const NotificationPopup = require('./notificationPopup/notificationPopup.jsx'); const Frame = require('react-frame-component').default; +const Themes = require('themes/themes.json'); + const PAGE_HEIGHT = 1056; const PPR_THRESHOLD = 50; @@ -23,6 +26,7 @@ const BrewRenderer = createClass({ text : '', style : '', renderer : 'legacy', + theme : '5ePHB', errors : [] }; }, @@ -105,7 +109,12 @@ const BrewRenderer = createClass({ renderPageInfo : function(){ return
+
+ {n}
) : null} +Username: {this.props.uiItems.username || 'No user currently logged in'}
+Last Login: {moment(this.props.uiItems.issued).format('dddd, MMMM Do YYYY, h:mm:ss a ZZ') || '-'}
+Brews on Homebrewery: {this.props.uiItems.mongoCount}
+Linked to Google: {this.props.uiItems.googleId ? 'YES' : 'NO'}
+ {this.props.uiItems.googleId && ++ Brews on Google Drive: {this.props.uiItems.googleCount ?? <>Unable to retrieve files - follow these steps to renew your Google credentials.>} +
+ } +{brew.description}
- Sort by :- |
- {this.renderSortOption('Title', 'alpha')}
- {this.renderSortOption('Created Date', 'created')}
- {this.renderSortOption('Updated Date', 'updated')}
- {this.renderSortOption('Views', 'views')}
- {/* {this.renderSortOption('Latest', 'latest')} */}
-
- Direction :- |
- - - | - {this.renderFilterOption()} -
-
-${text}`;
res.status(200).send(text);
-}));
+});
//Download brew source page
-app.get('/download/:id', asyncHandler(async (req, res)=>{
- const brew = await getBrewFromId(req.params.id, 'raw');
+app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
+ const { brew } = req;
+ sanitizeBrew(brew, 'share');
const prefix = 'HB - ';
+ const encodeRFC3986ValueChars = (str)=>{
+ return (
+ encodeURIComponent(str)
+ .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;})
+ );
+ };
+
let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', '');
if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; };
res.set({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/plain',
- 'Content-Disposition' : `attachment; filename="${fileName}.txt"`
+ 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
});
res.status(200).send(brew.text);
-}));
+});
//User Page
app.get('/user/:username', async (req, res, next)=>{
const ownAccount = req.account && (req.account.username == req.params.username);
- let brews = await HomebrewModel.getByUser(req.params.username, ownAccount)
+ req.ogMeta = { ...defaultMetaTags,
+ title : `${req.params.username}'s Collection`,
+ description : 'View my collection of homebrew on the Homebrewery.'
+ // type : could be 'profile'?
+ };
+
+ const fields = [
+ 'googleId',
+ 'title',
+ 'pageCount',
+ 'description',
+ 'authors',
+ 'published',
+ 'views',
+ 'shareId',
+ 'editId',
+ 'createdAt',
+ 'updatedAt',
+ 'lastViewed',
+ 'thumbnail',
+ 'tags'
+ ];
+
+ let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields)
.catch((err)=>{
console.log(err);
});
@@ -206,79 +248,201 @@ app.get('/user/:username', async (req, res, next)=>{
console.error(err);
});
- if(googleBrews) {
+ if(googleBrews && googleBrews.length > 0) {
+ for (const brew of brews.filter((brew)=>brew.googleId)) {
+ const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
+ if(match !== -1) {
+ brew.googleId = googleBrews[match].googleId;
+ brew.stubbed = true;
+ brew.pageCount = googleBrews[match].pageCount;
+ brew.renderer = googleBrews[match].renderer;
+ brew.version = googleBrews[match].version;
+ googleBrews.splice(match, 1);
+ }
+ }
+
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
brews = _.concat(brews, googleBrews);
}
}
req.brews = _.map(brews, (brew)=>{
- return sanitizeBrew(brew, !ownAccount);
+ return sanitizeBrew(brew, ownAccount ? 'edit' : 'share');
});
return next();
});
//Edit Page
-app.get('/edit/:id', asyncHandler(async (req, res, next)=>{
+app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
+ req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : req.brew.title || 'Untitled Brew',
+ description : req.brew.description || 'No description.',
+ image : req.brew.thumbnail || defaultMetaTags.image,
+ type : 'article'
+ };
+
+ sanitizeBrew(req.brew, 'edit');
+ splitTextStyleAndMetadata(req.brew);
res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
- const brew = await getBrewFromId(req.params.id, 'edit');
- req.brew = brew;
return next();
-}));
+});
//New Page
-app.get('/new/:id', asyncHandler(async (req, res, next)=>{
- const brew = await getBrewFromId(req.params.id, 'share');
- brew.title = `CLONE - ${brew.title}`;
- req.brew = brew;
+app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
+ sanitizeBrew(req.brew, 'share');
+ splitTextStyleAndMetadata(req.brew);
+ const brew = {
+ shareId : req.brew.shareId,
+ title : `CLONE - ${req.brew.title}`,
+ text : req.brew.text,
+ style : req.brew.style,
+ renderer : req.brew.renderer,
+ theme : req.brew.theme
+ };
+ req.brew = _.defaults(brew, DEFAULT_BREW);
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'New',
+ description : 'Start crafting your homebrew on the Homebrewery!'
+ };
+
return next();
-}));
+});
//Share Page
-app.get('/share/:id', asyncHandler(async (req, res, next)=>{
- const brew = await getBrewFromId(req.params.id, 'share');
+app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
+ const { brew } = req;
- if(req.params.id.length > 12) {
+ req.ogMeta = { ...defaultMetaTags,
+ title : req.brew.title || 'Untitled Brew',
+ description : req.brew.description || 'No description.',
+ image : req.brew.thumbnail || defaultMetaTags.image,
+ type : 'article'
+ };
+
+ if(req.params.id.length > 12 && !brew._id) {
const googleId = req.params.id.slice(0, -12);
const shareId = req.params.id.slice(-12);
await GoogleActions.increaseView(googleId, shareId, 'share', brew)
- .catch((err)=>{next(err);});
+ .catch((err)=>{next(err);});
} else {
await HomebrewModel.increaseView({ shareId: brew.shareId });
}
-
- req.brew = brew;
+ sanitizeBrew(req.brew, 'share');
+ splitTextStyleAndMetadata(req.brew);
return next();
}));
//Print Page
-app.get('/print/:id', asyncHandler(async (req, res, next)=>{
- const brew = await getBrewFromId(req.params.id, 'share');
- req.brew = brew;
+app.get('/print/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
+ sanitizeBrew(req.brew, 'share');
+ splitTextStyleAndMetadata(req.brew);
+ next();
+});
+
+//Account Page
+app.get('/account', asyncHandler(async (req, res, next)=>{
+ const data = {};
+ data.title = 'Account Information Page';
+
+ let auth;
+ let googleCount = [];
+ if(req.account) {
+ if(req.account.googleId) {
+ try {
+ auth = await GoogleActions.authCheck(req.account, res, false);
+ } catch (e) {
+ auth = undefined;
+ console.log('Google auth check failed!');
+ console.log(e);
+ }
+ if(auth.credentials.access_token) {
+ try {
+ googleCount = await GoogleActions.listGoogleBrews(auth);
+ } catch (e) {
+ googleCount = undefined;
+ console.log('List Google files failed!');
+ console.log(e);
+ }
+ }
+ }
+
+ const query = { authors: req.account.username, googleId: { $exists: false } };
+ const mongoCount = await HomebrewModel.countDocuments(query)
+ .catch((err)=>{
+ mongoCount = 0;
+ console.log(err);
+ });
+
+ data.uiItems = {
+ username : req.account.username,
+ issued : req.account.issued,
+ googleId : Boolean(req.account.googleId),
+ authCheck : Boolean(req.account.googleId && auth.credentials.access_token),
+ mongoCount : mongoCount,
+ googleCount : googleCount?.length
+ };
+ }
+
+ req.brew = data;
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : `Account Page`,
+ description : null
+ };
+
return next();
}));
+
+const nodeEnv = config.get('node_env');
+const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
+// Local only
+if(isLocalEnvironment){
+ // Login
+ app.post('/local/login', (req, res)=>{
+ const username = req.body.username;
+ if(!username) return;
+
+ const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret'));
+ return res.json(payload);
+ });
+}
+
//Render the page
const templateFn = require('./../client/template.js');
-app.use((req, res)=>{
+app.use(asyncHandler(async (req, res, next)=>{
+
+ // Create configuration object
+ const configuration = {
+ local : isLocalEnvironment,
+ publicUrl : config.get('publicUrl') ?? '',
+ environment : nodeEnv
+ };
const props = {
- version : require('./../package.json').version,
- url : req.originalUrl,
- brew : req.brew,
- brews : req.brews,
- googleBrews : req.googleBrews,
- account : req.account,
- enable_v3 : config.get('enable_v3')
+ version : require('./../package.json').version,
+ url : req.originalUrl,
+ brew : req.brew,
+ brews : req.brews,
+ googleBrews : req.googleBrews,
+ account : req.account,
+ enable_v3 : config.get('enable_v3'),
+ enable_themes : config.get('enable_themes'),
+ config : configuration,
+ ogMeta : req.ogMeta
};
const title = req.brew ? req.brew.title : '';
- templateFn('homebrew', title, props)
- .then((page)=>{ res.send(page); })
- .catch((err)=>{
- console.log(err);
- return res.sendStatus(500);
- });
-});
+ const page = await templateFn('homebrew', title, props)
+ .catch((err)=>{
+ console.log(err);
+ return res.sendStatus(500);
+ });
+ if(!page) return;
+ res.send(page);
+}));
//v=====----- Error-Handling Middleware -----=====v//
//Format Errors so all fields will be sent
@@ -302,6 +466,13 @@ app.use((err, req, res, next)=>{
console.error(err);
res.status(status).send(getPureError(err));
});
+
+app.use((req, res)=>{
+ if(!res.headersSent) {
+ console.error('Headers have not been sent, responding with a server error.', req.url);
+ res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.');
+ }
+});
//^=====--------------------------------------=====^//
module.exports = {
diff --git a/server/brewDefaults.js b/server/brewDefaults.js
new file mode 100644
index 000000000..30798cea7
--- /dev/null
+++ b/server/brewDefaults.js
@@ -0,0 +1,37 @@
+const _ = require('lodash');
+
+// Default properties for newly-created brews
+const DEFAULT_BREW = {
+ title : '',
+ text : '',
+ style : undefined,
+ description : '',
+ editId : undefined,
+ shareId : undefined,
+ createdAt : undefined,
+ updatedAt : undefined,
+ renderer : 'V3',
+ theme : '5ePHB',
+ authors : [],
+ tags : [],
+ systems : [],
+ thumbnail : '',
+ views : 0,
+ published : false,
+ pageCount : 1,
+ gDrive : false,
+ trashed : false
+
+};
+// Default values for older brews with missing properties
+// e.g., missing "renderer" is assumed to be "legacy"
+const DEFAULT_BREW_LOAD = _.defaults(
+ {
+ renderer : 'legacy',
+ },
+ DEFAULT_BREW);
+
+module.exports = {
+ DEFAULT_BREW,
+ DEFAULT_BREW_LOAD
+};
diff --git a/server/db.js b/server/db.js
index 030d7f61b..cd8308c1b 100644
--- a/server/db.js
+++ b/server/db.js
@@ -10,7 +10,7 @@ const Mongoose = require('mongoose');
const getMongoDBURL = (config)=>{
return config.get('mongodb_uri') ||
config.get('mongolab_uri') ||
- 'mongodb://localhost/homebrewery';
+ 'mongodb://127.0.0.1/homebrewery'; // changed from mongodb://localhost/homebrewery to accommodate versions 16+ of node.
};
const handleConnectionError = (error)=>{
diff --git a/server/googleActions.js b/server/googleActions.js
index 3a202cbce..e5fa9cc89 100644
--- a/server/googleActions.js
+++ b/server/googleActions.js
@@ -5,24 +5,28 @@ const { nanoid } = require('nanoid');
const token = require('./token.js');
const config = require('./config.js');
-const keys = typeof(config.get('service_account')) == 'string' ?
- JSON.parse(config.get('service_account')) :
- config.get('service_account');
let serviceAuth;
-try {
- serviceAuth = google.auth.fromJSON(keys);
- serviceAuth.scopes = [
- 'https://www.googleapis.com/auth/drive'
- ];
-} catch (err) {
- console.warn(err);
- console.log('Please make sure that a Google Service Account is set up properly in your config files.');
+if(!config.get('service_account')){
+ console.log('No Google Service Account in config files - Google Drive integration will not be available.');
+} else {
+ const keys = typeof(config.get('service_account')) == 'string' ?
+ JSON.parse(config.get('service_account')) :
+ config.get('service_account');
+
+ try {
+ serviceAuth = google.auth.fromJSON(keys);
+ serviceAuth.scopes = ['https://www.googleapis.com/auth/drive'];
+ } catch (err) {
+ console.warn(err);
+ console.log('Please make sure the Google Service Account is set up properly in your config files.');
+ }
}
+
google.options({ auth: serviceAuth || config.get('google_api_key') });
const GoogleActions = {
- authCheck : (account, res)=>{
+ authCheck : (account, res, updateTokens=true)=>{
if(!account || !account.googleId){ // If not signed into Google
const err = new Error('Not Signed In');
err.status = 401;
@@ -40,7 +44,7 @@ const GoogleActions = {
refresh_token : account.googleRefreshToken
});
- oAuth2Client.on('tokens', (tokens)=>{
+ updateTokens && oAuth2Client.on('tokens', (tokens)=>{
if(tokens.refresh_token) {
account.googleRefreshToken = tokens.refresh_token;
}
@@ -124,7 +128,6 @@ const GoogleActions = {
title : file.properties.title,
description : file.description,
views : parseInt(file.properties.views),
- tags : '',
published : file.properties.published ? file.properties.published == 'true' : false,
systems : []
};
@@ -142,12 +145,11 @@ const GoogleActions = {
description : `${brew.description}`,
properties : {
title : brew.title,
- published : brew.published,
- version : brew.version,
- renderer : brew.renderer,
- tags : brew.tags,
+ shareId : brew.shareId || nanoid(12),
+ editId : brew.editId || nanoid(12),
pageCount : brew.pageCount,
- systems : brew.systems.join()
+ renderer : brew.renderer || 'legacy',
+ isStubbed : true
}
},
media : {
@@ -159,10 +161,9 @@ const GoogleActions = {
console.log('Error saving to google');
console.error(err);
throw (err);
- //return res.status(500).send('Error while saving');
});
- return (brew);
+ return true;
},
newGoogleBrew : async (auth, brew)=>{
@@ -176,16 +177,17 @@ const GoogleActions = {
const folderId = await GoogleActions.getGoogleFolder(auth);
const fileMetadata = {
- 'name' : `${brew.title}.txt`,
- 'description' : `${brew.description}`,
- 'parents' : [folderId],
- 'properties' : { //AppProperties is not accessible
- 'shareId' : brew.shareId || nanoid(12),
- 'editId' : brew.editId || nanoid(12),
- 'title' : brew.title,
- 'views' : '0',
- 'pageCount' : brew.pageCount,
- 'renderer' : brew.renderer || 'legacy'
+ name : `${brew.title}.txt`,
+ description : `${brew.description}`,
+ parents : [folderId],
+ properties : { //AppProperties is not accessible
+ shareId : brew.shareId || nanoid(12),
+ editId : brew.editId || nanoid(12),
+ title : brew.title,
+ pageCount : brew.pageCount,
+ renderer : brew.renderer || 'legacy',
+ isStubbed : true,
+ version : 1
}
};
@@ -212,26 +214,7 @@ const GoogleActions = {
console.error(err);
});
- const newHomebrew = {
- text : brew.text,
- shareId : fileMetadata.properties.shareId,
- editId : fileMetadata.properties.editId,
- createdAt : new Date(),
- updatedAt : new Date(),
- gDrive : true,
- googleId : obj.data.id,
- pageCount : fileMetadata.properties.pageCount,
-
- title : brew.title,
- description : brew.description,
- tags : '',
- published : brew.published,
- renderer : brew.renderer,
- authors : [],
- systems : []
- };
-
- return newHomebrew;
+ return obj.data.id;
},
getGoogleBrew : async (id, accessId, accessType)=>{
@@ -244,7 +227,6 @@ const GoogleActions = {
.catch((err)=>{
console.log('Error loading from Google');
throw (err);
- return;
});
if(obj) {
@@ -254,9 +236,7 @@ const GoogleActions = {
throw ('Share ID does not match');
}
- const serviceDrive = google.drive({ version: 'v3' });
-
- const file = await serviceDrive.files.get({
+ const file = await drive.files.get({
fileId : id,
fields : 'description, properties',
alt : 'media'
@@ -273,7 +253,6 @@ const GoogleActions = {
text : file.data,
description : obj.data.description,
- tags : obj.data.properties.tags ? obj.data.properties.tags : '',
systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [],
authors : [],
published : obj.data.properties.published ? obj.data.properties.published == 'true' : false,
@@ -287,7 +266,6 @@ const GoogleActions = {
version : parseInt(obj.data.properties.version) || 0,
renderer : obj.data.properties.renderer ? obj.data.properties.renderer : 'legacy',
- gDrive : true,
googleId : id
};
@@ -295,14 +273,11 @@ const GoogleActions = {
}
},
- deleteGoogleBrew : async (auth, id)=>{
+ deleteGoogleBrew : async (auth, id, accessId)=>{
const drive = google.drive({ version: 'v3', auth });
- const googleId = id.slice(0, -12);
- const accessId = id.slice(-12);
-
const obj = await drive.files.get({
- fileId : googleId,
+ fileId : id,
fields : 'properties'
})
.catch((err)=>{
@@ -311,11 +286,11 @@ const GoogleActions = {
});
if(obj && obj.data.properties.editId != accessId) {
- throw ('Not authorized to delete this Google brew');
+ throw { status: 403, message: 'Not authorized to delete this Google brew' };
}
await drive.files.update({
- fileId : googleId,
+ fileId : id,
resource : { trashed: true }
})
.catch((err)=>{
diff --git a/server/homebrew.api.js b/server/homebrew.api.js
index 4415c948b..6f5fcb1ef 100644
--- a/server/homebrew.api.js
+++ b/server/homebrew.api.js
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines */
const _ = require('lodash');
const HomebrewModel = require('./homebrew.model.js').model;
const router = require('express').Router();
@@ -6,6 +7,9 @@ const GoogleActions = require('./googleActions.js');
const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
+const { nanoid } = require('nanoid');
+
+const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
// const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
@@ -13,224 +17,331 @@ const asyncHandler = require('express-async-handler');
// });
// };
-const mergeBrewText = (brew)=>{
- let text = brew.text;
- if(brew.style !== undefined) {
- text = `\`\`\`css\n` +
- `${brew.style || ''}\n` +
- `\`\`\`\n\n` +
- `${text}`;
- }
- const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer']);
- text = `\`\`\`metadata\n` +
- `${yaml.dump(metadata)}\n` +
- `\`\`\`\n\n` +
- `${text}`;
- return text;
-};
-
const MAX_TITLE_LENGTH = 100;
-const getGoodBrewTitle = (text)=>{
- const tokens = Markdown.marked.lexer(text);
- return (tokens.find((token)=>token.type == 'heading' || token.type == 'paragraph')?.text || 'No Title')
- .slice(0, MAX_TITLE_LENGTH);
-};
+const api = {
+ homebrewApi : router,
+ getId : (req)=>{
+ // Set the id and initial potential google id, where the google id is present on the existing brew.
+ let id = req.params.id, googleId = req.body?.googleId;
-const excludePropsFromUpdate = (brew)=>{
- // Remove undesired properties
- const propsToExclude = ['views', 'lastViewed'];
- for (const prop of propsToExclude) {
- delete brew[prop];
- }
- return brew;
-};
+ // If the id is longer than 12, then it's a google id + the edit id. This splits the longer id up.
+ if(id.length > 12) {
+ googleId = id.slice(0, -12);
+ id = id.slice(-12);
+ }
+ return { id, googleId };
+ },
+ getBrew : (accessType, stubOnly = false)=>{
+ // Create middleware with the accessType passed in as part of the scope
+ return async (req, res, next)=>{
+ // Get relevant IDs for the brew
+ const { id, googleId } = api.getId(req);
-const beforeNewSave = (account, brew)=>{
- if(!brew.title) {
- brew.title = getGoodBrewTitle(brew.text);
- }
+ // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
+ let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
+ .catch((err)=>{
+ if(googleId) {
+ console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
+ } else {
+ console.warn(err);
+ }
+ });
+ stub = stub?.toObject();
- brew.authors = (account) ? [account.username] : [];
- brew.text = mergeBrewText(brew);
-};
+ // If there is a google id, try to find the google brew
+ if(!stubOnly && (googleId || stub?.googleId)) {
+ let googleError;
+ const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType)
+ .catch((err)=>{
+ googleError = err;
+ });
+ // Throw any error caught while attempting to retrieve Google brew.
+ if(googleError) throw googleError;
+ // Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew
+ stub = stub ? _.assign({ ...api.excludeStubProps(stub), stubbed: true }, api.excludeGoogleProps(googleBrew)) : googleBrew;
+ }
+ const authorsExist = stub?.authors?.length > 0;
+ const isAuthor = stub?.authors?.includes(req.account?.username);
+ const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
+ if(accessType === 'edit' && (authorsExist && !(isAuthor || isInvited))) {
+ throw `The current logged in user does not have editor access to this brew.
-const newLocalBrew = async (brew)=>{
- const newHomebrew = new HomebrewModel(brew);
- // Compress brew text to binary before saving
- newHomebrew.textBin = zlib.deflateRawSync(newHomebrew.text);
- // Delete the non-binary text field since it's not needed anymore
- newHomebrew.text = undefined;
+If you believe you should have access to this brew, ask the file owner to invite you as an author by opening the brew, viewing the Properties tab, and adding your username to the "invited authors" list. You can then try to access this document again.`;
+ }
- let saved = await newHomebrew.save()
- .catch((err)=>{
- console.error(err, err.toString(), err.stack);
- throw `Error while creating new brew, ${err.toString()}`;
- });
+ // If after all of that we still don't have a brew, throw an exception
+ if(!stub && !stubOnly) {
+ throw 'Brew not found in Homebrewery database or Google Drive';
+ }
- saved = saved.toObject();
- saved.gDrive = false;
- return saved;
-};
+ // Clean up brew: fill in missing fields with defaults / fix old invalid values
+ if(stub) {
+ stub.tags = stub.tags || undefined; // Clear empty strings
+ stub.renderer = stub.renderer || undefined; // Clear empty strings
+ stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
+ }
-const newGoogleBrew = async (account, brew, res)=>{
- const oAuth2Client = GoogleActions.authCheck(account, res);
+ req.brew = stub ?? {};
+ next();
+ };
+ },
+ mergeBrewText : (brew)=>{
+ let text = brew.text;
+ if(brew.style !== undefined) {
+ text = `\`\`\`css\n` +
+ `${brew.style || ''}\n` +
+ `\`\`\`\n\n` +
+ `${text}`;
+ }
+ const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']);
+ text = `\`\`\`metadata\n` +
+ `${yaml.dump(metadata)}\n` +
+ `\`\`\`\n\n` +
+ `${text}`;
+ return text;
+ },
+ getGoodBrewTitle : (text)=>{
+ const tokens = Markdown.marked.lexer(text);
+ return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
+ .slice(0, MAX_TITLE_LENGTH);
+ },
+ excludePropsFromUpdate : (brew)=>{
+ // Remove undesired properties
+ const modified = _.clone(brew);
+ const propsToExclude = ['_id', 'views', 'lastViewed'];
+ for (const prop of propsToExclude) {
+ delete modified[prop];
+ }
+ return modified;
+ },
+ excludeGoogleProps : (brew)=>{
+ const modified = _.clone(brew);
+ const propsToExclude = ['version', 'tags', 'systems', 'published', 'authors', 'owner', 'views', 'thumbnail'];
+ for (const prop of propsToExclude) {
+ delete modified[prop];
+ }
+ return modified;
+ },
+ excludeStubProps : (brew)=>{
+ const propsToExclude = ['text', 'textBin', 'renderer', 'pageCount'];
+ for (const prop of propsToExclude) {
+ brew[prop] = undefined;
+ }
+ return brew;
+ },
+ beforeNewSave : (account, brew)=>{
+ if(!brew.title) {
+ brew.title = api.getGoodBrewTitle(brew.text);
+ }
- return await GoogleActions.newGoogleBrew(oAuth2Client, brew);
-};
+ brew.authors = (account) ? [account.username] : [];
+ brew.text = api.mergeBrewText(brew);
-const newBrew = async (req, res)=>{
- const brew = req.body;
- const { transferToGoogle } = req.query;
+ _.defaults(brew, DEFAULT_BREW);
+ },
+ newGoogleBrew : async (account, brew, res)=>{
+ const oAuth2Client = GoogleActions.authCheck(account, res);
- delete brew.editId;
- delete brew.shareId;
- delete brew.googleId;
+ const newBrew = api.excludeGoogleProps(brew);
- beforeNewSave(req.account, brew);
+ return await GoogleActions.newGoogleBrew(oAuth2Client, newBrew);
+ },
+ newBrew : async (req, res)=>{
+ const brew = req.body;
+ const { saveToGoogle } = req.query;
- let saved;
- if(transferToGoogle) {
- saved = await newGoogleBrew(req.account, brew, res)
+ delete brew.editId;
+ delete brew.shareId;
+ delete brew.googleId;
+
+ api.beforeNewSave(req.account, brew);
+
+ const newHomebrew = new HomebrewModel(brew);
+ newHomebrew.editId = nanoid(12);
+ newHomebrew.shareId = nanoid(12);
+
+ let googleId, saved;
+ if(saveToGoogle) {
+ googleId = await api.newGoogleBrew(req.account, newHomebrew, res)
+ .catch((err)=>{
+ console.error(err);
+ res.status(err?.status || err?.response?.status || 500).send(err?.message || err);
+ });
+ if(!googleId) return;
+ api.excludeStubProps(newHomebrew);
+ newHomebrew.googleId = googleId;
+ } else {
+ // Compress brew text to binary before saving
+ newHomebrew.textBin = zlib.deflateRawSync(newHomebrew.text);
+ // Delete the non-binary text field since it's not needed anymore
+ newHomebrew.text = undefined;
+ }
+
+ saved = await newHomebrew.save()
.catch((err)=>{
- res.status(err.status || err.response.status).send(err.message || err);
- });
- } else {
- saved = await newLocalBrew(brew)
- .catch((err)=>{
- res.status(500).send(err);
- });
- }
- if(!saved) return;
- return res.status(200).send(saved);
-};
-
-const updateBrew = async (req, res)=>{
- let brew = excludePropsFromUpdate(req.body);
- const { transferToGoogle, transferFromGoogle } = req.query;
-
- let saved;
- if(brew.googleId && transferFromGoogle) {
- beforeNewSave(req.account, brew);
-
- saved = await newLocalBrew(brew)
- .catch((err)=>{
- console.error(err);
- res.status(500).send(err);
+ console.error(err, err.toString(), err.stack);
+ throw `Error while creating new brew, ${err.toString()}`;
});
if(!saved) return;
+ saved = saved.toObject();
- await deleteGoogleBrew(req.account, `${brew.googleId}${brew.editId}`, res)
- .catch((err)=>{
- console.error(err);
- res.status(err.status || err.response.status).send(err.message || err);
- });
- } else if(!brew.googleId && transferToGoogle) {
- saved = await newGoogleBrew(req.account, brew, res)
- .catch((err)=>{
- console.error(err);
- res.status(err.status || err.response.status).send(err.message || err);
- });
- if(!saved) return;
+ res.status(200).send(saved);
+ },
+ updateBrew : async (req, res)=>{
+ // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
+ const brewFromClient = api.excludePropsFromUpdate(req.body);
+ const brewFromServer = req.brew;
+ if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) {
+ console.log(`Version mismatch on brew ${brewFromClient.editId}`);
+ res.setHeader('Content-Type', 'application/json');
+ return res.status(409).send(JSON.stringify({ message: `The brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` }));
+ }
- await deleteLocalBrew(req.account, brew.editId)
- .catch((err)=>{
- console.error(err);
- res.status(err.status).send(err.message);
- });
- } else if(brew.googleId) {
- brew.text = mergeBrewText(brew);
+ let brew = _.assign(brewFromServer, brewFromClient);
+ const googleId = brew.googleId;
+ const { saveToGoogle, removeFromGoogle } = req.query;
+ let afterSave = async ()=>true;
- saved = await GoogleActions.updateGoogleBrew(brew)
- .catch((err)=>{
- console.error(err);
- res.status(err.response?.status || 500).send(err);
- });
- } else {
- const dbBrew = await HomebrewModel.get({ editId: req.params.id })
- .catch((err)=>{
- console.error(err);
- return res.status(500).send('Error while saving');
- });
+ brew.text = api.mergeBrewText(brew);
- brew = _.merge(dbBrew, brew);
- brew.text = mergeBrewText(brew);
+ if(brew.googleId && removeFromGoogle) {
+ // If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined
+ afterSave = async ()=>{
+ return await api.deleteGoogleBrew(req.account, googleId, brew.editId, res)
+ .catch((err)=>{
+ console.error(err);
+ res.status(err?.status || err?.response?.status || 500).send(err.message || err);
+ });
+ };
- // Compress brew text to binary before saving
- brew.textBin = zlib.deflateRawSync(brew.text);
- // Delete the non-binary text field since it's not needed anymore
- brew.text = undefined;
+ brew.googleId = undefined;
+ } else if(!brew.googleId && saveToGoogle) {
+ // If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew
+ brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res)
+ .catch((err)=>{
+ console.error(err);
+ res.status(err.status || err.response.status).send(err.message || err);
+ });
+ if(!brew.googleId) return;
+ } else if(brew.googleId) {
+ // If the google id exists and no other actions are being performed, update the google brew
+ const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew))
+ .catch((err)=>{
+ console.error(err);
+ res.status(err?.response?.status || 500).send(err);
+ });
+ if(!updated) return;
+ }
+
+ if(brew.googleId) {
+ // If the google id exists after all those actions, exclude the props that are stored in google and aren't needed for rendering the brew items
+ api.excludeStubProps(brew);
+ } else {
+ // Compress brew text to binary before saving
+ brew.textBin = zlib.deflateRawSync(brew.text);
+ // Delete the non-binary text field since it's not needed anymore
+ brew.text = undefined;
+ }
brew.updatedAt = new Date();
+ brew.version = (brew.version || 1) + 1;
if(req.account) {
brew.authors = _.uniq(_.concat(brew.authors, req.account.username));
+ brew.invitedAuthors = _.uniq(_.filter(brew.invitedAuthors, (a)=>req.account.username !== a));
}
- brew.markModified('authors');
- brew.markModified('systems');
+ // define a function to catch our save errors
+ const saveError = (err)=>{
+ console.error(err);
+ res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database');
+ };
+ let saved;
+ if(!brew._id) {
+ // if the brew does not have a stub id, create and save it, then write the new value back to the brew.
+ saved = await new HomebrewModel(brew).save().catch(saveError);
+ } else {
+ // if the brew does have a stub id, update it using the stub id as the key.
+ brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew);
+ saved = await brew.save()
+ .catch(saveError);
+ }
+ if(!saved) return;
+ // Call and wait for afterSave to complete
+ const after = await afterSave();
+ if(!after) return;
- saved = await brew.save();
- }
- if(!saved) return;
+ res.status(200).send(saved);
+ },
+ deleteGoogleBrew : async (account, id, editId, res)=>{
+ const auth = await GoogleActions.authCheck(account, res);
+ await GoogleActions.deleteGoogleBrew(auth, id, editId);
+ return true;
+ },
+ deleteBrew : async (req, res, next)=>{
+ // Delete an orphaned stub if its Google brew doesn't exist
+ try {
+ await api.getBrew('edit')(req, res, ()=>{});
+ } catch (err) {
+ const { id, googleId } = api.getId(req);
+ console.warn(`No google brew found for id ${googleId}, the stub with id ${id} will be deleted.`);
+ await HomebrewModel.deleteOne({ editId: id });
+ return next();
+ }
- if(!res.headersSent) return res.status(200).send(saved);
-};
+ let brew = req.brew;
+ const { googleId, editId } = brew;
+ const account = req.account;
+ const isOwner = account && (brew.authors.length === 0 || brew.authors[0] === account.username);
+ // If the user is the owner and the file is saved to google, mark the google brew for deletion
+ const shouldDeleteGoogleBrew = googleId && isOwner;
-const deleteBrew = async (req, res)=>{
- if(req.params.id.length > 12) {
- const deleted = await deleteGoogleBrew(req.account, req.params.id, res)
- .catch((err)=>{
- res.status(500).send(err);
- });
- if(deleted) return res.status(200).send();
- } else {
- const deleted = await deleteLocalBrew(req.account, req.params.id)
- .catch((err)=>{
- res.status(err.status).send(err.message);
- });
- if(deleted) return res.status(200).send(deleted);
- return res.status(200).send();
+ if(brew._id) {
+ brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew);
+ if(account) {
+ // Remove current user as author
+ brew.authors = _.pull(brew.authors, account.username);
+ }
+
+ if(brew.authors.length === 0) {
+ // Delete brew if there are no authors left
+ await brew.remove()
+ .catch((err)=>{
+ console.error(err);
+ throw { status: 500, message: 'Error while removing' };
+ });
+ } else {
+ if(shouldDeleteGoogleBrew) {
+ // When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database
+ brew.googleId = undefined;
+ brew.textBin = zlib.deflateRawSync(brew.text);
+ brew.text = undefined;
+ }
+ brew.markModified('authors'); //Mongo will not properly update arrays without markModified()
+ await brew.save()
+ .catch((err)=>{
+ throw { status: 500, message: err };
+ });
+ }
+ }
+ if(shouldDeleteGoogleBrew) {
+ const deleted = await api.deleteGoogleBrew(account, googleId, editId, res)
+ .catch((err)=>{
+ console.error(err);
+ res.status(500).send(err);
+ });
+ if(!deleted) return;
+ }
+
+ res.status(204).send();
}
};
-const deleteLocalBrew = async (account, id)=>{
- const brew = await HomebrewModel.findOne({ editId: id });
- if(!brew) {
- throw { status: 404, message: 'Can not find homebrew with that id' };
- }
+router.use('/api', require('./middleware/check-client-version.js'));
+router.post('/api', asyncHandler(api.newBrew));
+router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
+router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
+router.delete('/api/:id', asyncHandler(api.deleteBrew));
+router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
- if(account) {
- // Remove current user as author
- brew.authors = _.pull(brew.authors, account.username);
- brew.markModified('authors');
- }
-
- if(brew.authors.length === 0) {
- // Delete brew if there are no authors left
- await brew.remove()
- .catch((err)=>{
- console.error(err);
- throw { status: 500, message: 'Error while removing' };
- });
- } else {
- // Otherwise, save the brew with updated author list
- return await brew.save()
- .catch((err)=>{
- throw { status: 500, message: err };
- });
- }
-};
-
-const deleteGoogleBrew = async (account, id, res)=>{
- const auth = await GoogleActions.authCheck(account, res);
- await GoogleActions.deleteGoogleBrew(auth, id);
- return true;
-};
-
-router.post('/api', asyncHandler(newBrew));
-router.put('/api/:id', asyncHandler(updateBrew));
-router.put('/api/update/:id', asyncHandler(updateBrew));
-router.delete('/api/:id', asyncHandler(deleteBrew));
-router.get('/api/remove/:id', asyncHandler(deleteBrew));
-
-module.exports = router;
+module.exports = api;
diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js
new file mode 100644
index 000000000..3f3eb9794
--- /dev/null
+++ b/server/homebrew.api.spec.js
@@ -0,0 +1,758 @@
+/* eslint-disable max-lines */
+
+describe('Tests for api', ()=>{
+ let api;
+ let google;
+ let model;
+ let hbBrew;
+ let googleBrew;
+ let res;
+
+ let modelBrew;
+ let saveFunc;
+ let removeFunc;
+ let markModifiedFunc;
+ let saved;
+
+ beforeEach(()=>{
+ saved = undefined;
+ saveFunc = jest.fn(async function() {
+ saved = { ...this, _id: '1' };
+ return saved;
+ });
+ removeFunc = jest.fn(async function() {});
+ markModifiedFunc = jest.fn(()=>true);
+
+ modelBrew = (brew)=>({
+ ...brew,
+ save : saveFunc,
+ remove : removeFunc,
+ markModified : markModifiedFunc,
+ toObject : function() {
+ delete this.save;
+ delete this.toObject;
+ delete this.remove;
+ delete this.markModified;
+ return this;
+ }
+ });
+
+ google = require('./googleActions.js');
+ model = require('./homebrew.model.js').model;
+
+ jest.mock('./googleActions.js');
+ google.authCheck = jest.fn(()=>'client');
+ google.newGoogleBrew = jest.fn(()=>'id');
+ google.deleteGoogleBrew = jest.fn(()=>true);
+
+ jest.mock('./homebrew.model.js');
+ model.mockImplementation((brew)=>modelBrew(brew));
+
+ res = {
+ status : jest.fn(()=>res),
+ send : jest.fn(()=>{})
+ };
+
+ api = require('./homebrew.api');
+
+ hbBrew = {
+ text : `brew text`,
+ style : 'hello yes i am css',
+ title : 'some title',
+ description : 'this is a description',
+ tags : ['something', 'fun'],
+ systems : ['D&D 5e'],
+ renderer : 'v3',
+ theme : 'phb',
+ published : true,
+ authors : ['1', '2'],
+ owner : '1',
+ thumbnail : '',
+ _id : 'mongoid',
+ editId : 'abcdefg',
+ shareId : 'hijklmnop',
+ views : 1,
+ lastViewed : new Date(),
+ version : 1,
+ pageCount : 1,
+ textBin : '',
+ views : 0
+ };
+ googleBrew = {
+ ...hbBrew,
+ googleId : '12345'
+ };
+ });
+
+ afterEach(()=>{
+ jest.restoreAllMocks();
+ });
+
+ describe('getId', ()=>{
+ it('should return only id if google id is not present', ()=>{
+ const { id, googleId } = api.getId({
+ params : {
+ id : 'abcdefgh'
+ }
+ });
+
+ expect(id).toEqual('abcdefgh');
+ expect(googleId).toBeUndefined();
+ });
+
+ it('should return id and google id from request body', ()=>{
+ const { id, googleId } = api.getId({
+ params : {
+ id : 'abcdefgh'
+ },
+ body : {
+ googleId : '12345'
+ }
+ });
+
+ expect(id).toEqual('abcdefgh');
+ expect(googleId).toEqual('12345');
+ });
+
+ it('should return id and google id from params', ()=>{
+ const { id, googleId } = api.getId({
+ params : {
+ id : '123456789012abcdefghijkl'
+ }
+ });
+
+ expect(id).toEqual('abcdefghijkl');
+ expect(googleId).toEqual('123456789012');
+ });
+ });
+
+ describe('getBrew', ()=>{
+ const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
+ const notFoundError = 'Brew not found in Homebrewery database or Google Drive';
+
+ it('returns middleware', ()=>{
+ const getFn = api.getBrew('share');
+ expect(getFn).toBeInstanceOf(Function);
+ });
+
+ it('should fetch from mongoose', async ()=>{
+ const testBrew = { title: 'test brew', authors: [] };
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>toBrewPromise(testBrew));
+
+ const fn = api.getBrew('share', true);
+ const req = { brew: {} };
+ const next = jest.fn();
+ await fn(req, null, next);
+
+ expect(req.brew).toEqual(testBrew);
+ expect(next).toHaveBeenCalled();
+ expect(api.getId).toHaveBeenCalledWith(req);
+ expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
+ });
+
+ it('should handle mongoose error', async ()=>{
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>new Promise((_, rej)=>rej('Unable to find brew')));
+
+ const fn = api.getBrew('share', false);
+ const req = { brew: {} };
+ const next = jest.fn();
+ let err;
+ try {
+ await fn(req, null, next);
+ } catch (e) {
+ err = e;
+ }
+
+ expect(err).toEqual(notFoundError);
+ expect(req.brew).toEqual({});
+ expect(next).not.toHaveBeenCalled();
+ expect(api.getId).toHaveBeenCalledWith(req);
+ expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
+ });
+
+ it('changes tags from string to array', async ()=>{
+ const testBrew = { title: 'test brew', authors: [], tags: '' };
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>toBrewPromise(testBrew));
+
+ const fn = api.getBrew('share', true);
+ const req = { brew: {} };
+ const next = jest.fn();
+ await fn(req, null, next);
+
+ expect(req.brew.tags).toEqual([]);
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('throws if invalid author', async ()=>{
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: ['a'] }));
+
+ const fn = api.getBrew('edit', true);
+ const req = { brew: {} };
+
+ let err;
+ try {
+ await fn(req, null, null);
+ } catch (e) {
+ err = e;
+ }
+
+ expect(err).toEqual(`The current logged in user does not have editor access to this brew.
+
+If you believe you should have access to this brew, ask the file owner to invite you as an author by opening the brew, viewing the Properties tab, and adding your username to the "invited authors" list. You can then try to access this document again.`);
+ });
+
+ it('does not throw if no authors', async ()=>{
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: [] }));
+
+ const fn = api.getBrew('edit', true);
+ const req = { brew: {} };
+ const next = jest.fn();
+ await fn(req, null, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.brew.title).toEqual('test brew');
+ expect(req.brew.authors).toEqual([]);
+ });
+
+ it('does not throw if valid author', async ()=>{
+ api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
+ model.get = jest.fn(()=>toBrewPromise({ title: 'test brew', authors: ['a'] }));
+
+ const fn = api.getBrew('edit', true);
+ const req = { brew: {}, account: { username: 'a' } };
+ const next = jest.fn();
+ await fn(req, null, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.brew.title).toEqual('test brew');
+ expect(req.brew.authors).toEqual(['a']);
+ });
+
+ it('fetches google brew if needed', async()=>{
+ const stubBrew = { title: 'test brew', authors: ['a'] };
+ const googleBrew = { title: 'test google brew', text: 'brew text' };
+ api.getId = jest.fn(()=>({ id: '1', googleId: '2' }));
+ model.get = jest.fn(()=>toBrewPromise(stubBrew));
+ google.getGoogleBrew = jest.fn(()=>new Promise((res)=>res(googleBrew)));
+
+ const fn = api.getBrew('share', false);
+ const req = { brew: {} };
+ const next = jest.fn();
+ await fn(req, null, next);
+
+ expect(req.brew).toEqual({
+ title : 'test google brew',
+ authors : ['a'],
+ text : 'brew text',
+ stubbed : true,
+ description : '',
+ editId : undefined,
+ pageCount : 1,
+ published : false,
+ renderer : 'legacy',
+ shareId : undefined,
+ systems : [],
+ tags : [],
+ theme : '5ePHB',
+ thumbnail : '',
+ textBin : undefined,
+ version : undefined,
+ createdAt : undefined,
+ gDrive : false,
+ style : undefined,
+ trashed : false,
+ updatedAt : undefined,
+ views : 0
+ });
+ expect(next).toHaveBeenCalled();
+ expect(api.getId).toHaveBeenCalledWith(req);
+ expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
+ expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
+ });
+ });
+
+ describe('mergeBrewText', ()=>{
+ it('should set metadata and no style if it is not present', ()=>{
+ const result = api.mergeBrewText({
+ text : `brew`,
+ title : 'some title',
+ description : 'this is a description',
+ tags : ['something', 'fun'],
+ systems : ['D&D 5e'],
+ renderer : 'v3',
+ theme : 'phb',
+ googleId : '12345'
+ });
+
+ expect(result).toEqual(`\`\`\`metadata
+title: some title
+description: this is a description
+tags:
+ - something
+ - fun
+systems:
+ - D&D 5e
+renderer: v3
+theme: phb
+
+\`\`\`
+
+brew`);
+ });
+
+ it('should set metadata and style', ()=>{
+ const result = api.mergeBrewText({
+ text : `brew`,
+ style : 'hello yes i am css',
+ title : 'some title',
+ description : 'this is a description',
+ tags : ['something', 'fun'],
+ systems : ['D&D 5e'],
+ renderer : 'v3',
+ theme : 'phb',
+ googleId : '12345'
+ });
+
+ expect(result).toEqual(`\`\`\`metadata
+title: some title
+description: this is a description
+tags:
+ - something
+ - fun
+systems:
+ - D&D 5e
+renderer: v3
+theme: phb
+
+\`\`\`
+
+\`\`\`css
+hello yes i am css
+\`\`\`
+
+brew`);
+ });
+ });
+
+ describe('exclusion methods', ()=>{
+ it('excludePropsFromUpdate removes the correct keys', ()=>{
+ const sent = Object.assign({}, googleBrew);
+ const result = api.excludePropsFromUpdate(sent);
+
+ expect(sent).toEqual(googleBrew);
+ expect(result._id).toBeUndefined();
+ expect(result.views).toBeUndefined();
+ expect(result.lastViewed).toBeUndefined();
+ });
+
+ it('excludeGoogleProps removes the correct keys', ()=>{
+ const sent = Object.assign({}, googleBrew);
+ const result = api.excludeGoogleProps(sent);
+
+ expect(sent).toEqual(googleBrew);
+ expect(result.tags).toBeUndefined();
+ expect(result.systems).toBeUndefined();
+ expect(result.published).toBeUndefined();
+ expect(result.authors).toBeUndefined();
+ expect(result.owner).toBeUndefined();
+ expect(result.views).toBeUndefined();
+ expect(result.thumbnail).toBeUndefined();
+ expect(result.version).toBeUndefined();
+ });
+
+ it('excludeStubProps removes the correct keys from the original object', ()=>{
+ const sent = Object.assign({}, googleBrew);
+ const result = api.excludeStubProps(sent);
+
+ expect(sent).not.toEqual(googleBrew);
+ expect(result.text).toBeUndefined();
+ expect(result.textBin).toBeUndefined();
+ expect(result.renderer).toBeUndefined();
+ expect(result.pageCount).toBeUndefined();
+ });
+ });
+
+ describe('beforeNewSave', ()=>{
+ it('sets the title if none', ()=>{
+ const brew = {
+ ...hbBrew,
+ title : undefined
+ };
+ api.beforeNewSave({}, brew);
+
+ expect(brew.title).toEqual('brew text');
+ });
+
+ it('does not override the title if present', ()=>{
+ const brew = {
+ ...hbBrew,
+ title : 'test'
+ };
+ api.beforeNewSave({}, brew);
+
+ expect(brew.title).toEqual('test');
+ });
+
+ it('does not set authors if account missing username', ()=>{
+ api.beforeNewSave({}, hbBrew);
+
+ expect(hbBrew.authors).toEqual([]);
+ });
+
+ it('sets authors if account has username', ()=>{
+ api.beforeNewSave({ username: 'hi' }, hbBrew);
+
+ expect(hbBrew.authors).toEqual(['hi']);
+ });
+
+ it('merges brew text', ()=>{
+ api.mergeBrewText = jest.fn(()=>'merged');
+ api.beforeNewSave({}, hbBrew);
+
+ expect(api.mergeBrewText).toHaveBeenCalled();
+ expect(hbBrew.text).toEqual('merged');
+ });
+ });
+
+ describe('newGoogleBrew', ()=>{
+ it('should call the correct methods', ()=>{
+ api.excludeGoogleProps = jest.fn(()=>'newBrew');
+
+ const acct = { username: 'test' };
+ const brew = { title: 'test title' };
+ api.newGoogleBrew(acct, brew, res);
+
+ expect(google.authCheck).toHaveBeenCalledWith(acct, res);
+ expect(api.excludeGoogleProps).toHaveBeenCalledWith(brew);
+ expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew');
+ });
+ });
+
+ describe('newBrew', ()=>{
+ it('should set up a default brew via Homebrew model', async ()=>{
+ await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ _id : '1',
+ authors : ['test user'],
+ createdAt : undefined,
+ description : '',
+ editId : expect.any(String),
+ gDrive : false,
+ pageCount : 1,
+ published : false,
+ renderer : 'V3',
+ shareId : expect.any(String),
+ style : undefined,
+ systems : [],
+ tags : [],
+ text : undefined,
+ textBin : expect.objectContaining({}),
+ theme : '5ePHB',
+ thumbnail : '',
+ title : 'asdf',
+ trashed : false,
+ updatedAt : undefined,
+ views : 0
+ });
+ });
+
+ it('should remove edit/share/google ids', async ()=>{
+ await api.newBrew({ body: { editId: '1234', shareId: '1234', googleId: '1234', text: 'asdf', title: '' }, query: {} }, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalled();
+ const sent = res.send.mock.calls[0][0];
+ expect(sent.editId).not.toEqual('1234');
+ expect(sent.shareId).not.toEqual('1234');
+ expect(sent.googleId).toBeUndefined();
+ });
+
+ it('should handle mongo error', async ()=>{
+ saveFunc = jest.fn(async function() {
+ throw 'err';
+ });
+ model.mockImplementation((brew)=>modelBrew(brew));
+
+ let err;
+ try {
+ await api.newBrew({ body: { editId: '1234', shareId: '1234', googleId: '1234', text: 'asdf', title: '' }, query: {} }, res);
+ } catch (e) {
+ err = e;
+ }
+
+ expect(res.send).not.toHaveBeenCalled();
+ expect(err).not.toBeUndefined();
+ });
+
+ it('should save to google if requested', async()=>{
+ await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res);
+
+ expect(google.newGoogleBrew).toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ _id : '1',
+ authors : ['test user'],
+ createdAt : undefined,
+ description : '',
+ editId : expect.any(String),
+ gDrive : false,
+ pageCount : undefined,
+ published : false,
+ renderer : undefined,
+ shareId : expect.any(String),
+ googleId : expect.any(String),
+ style : undefined,
+ systems : [],
+ tags : [],
+ text : undefined,
+ textBin : undefined,
+ theme : '5ePHB',
+ thumbnail : '',
+ title : 'asdf',
+ trashed : false,
+ updatedAt : undefined,
+ views : 0
+ });
+ });
+
+ it('should handle google error', async()=>{
+ google.newGoogleBrew = jest.fn(()=>{
+ throw 'err';
+ });
+ await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.send).toHaveBeenCalledWith('err');
+ });
+ });
+
+ describe('deleteGoogleBrew', ()=>{
+ it('should check auth and delete brew', async ()=>{
+ const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
+
+ expect(result).toBe(true);
+ expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
+ expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
+ });
+ });
+
+ describe('deleteBrew', ()=>{
+ it('should handle case where fetching the brew returns an error', async ()=>{
+ api.getBrew = jest.fn(()=>async ()=>{ throw 'err'; });
+ api.getId = jest.fn(()=>({ id: '1', googleId: '2' }));
+ model.deleteOne = jest.fn(async ()=>{});
+ const next = jest.fn(()=>{});
+
+ await api.deleteBrew(null, null, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(model.deleteOne).toHaveBeenCalledWith({ editId: '1' });
+ });
+
+ it('should delete if no authors', async ()=>{
+ const brew = {
+ ...hbBrew,
+ authors : []
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ const req = {};
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).toHaveBeenCalled();
+ });
+
+ it('should throw on delete error', async ()=>{
+ const brew = {
+ ...hbBrew,
+ authors : []
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ removeFunc = jest.fn(async ()=>{ throw 'err'; });
+ const req = {};
+
+ let err;
+ try {
+ await api.deleteBrew(req, res);
+ } catch (e) {
+ err = e;
+ }
+
+ expect(err).not.toBeUndefined();
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).toHaveBeenCalled();
+ });
+
+ it('should delete when one author', async ()=>{
+ const brew = {
+ ...hbBrew,
+ authors : ['test']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ const req = { account: { username: 'test' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).toHaveBeenCalled();
+ });
+
+ it('should remove one author when multiple present', async ()=>{
+ const brew = {
+ ...hbBrew,
+ authors : ['test', 'test2']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ const req = { account: { username: 'test' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(markModifiedFunc).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).not.toHaveBeenCalled();
+ expect(saveFunc).toHaveBeenCalled();
+ expect(saved.authors).toEqual(['test2']);
+ });
+
+ it('should handle save error', async ()=>{
+ const brew = {
+ ...hbBrew,
+ authors : ['test', 'test2']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ saveFunc = jest.fn(async ()=>{ throw 'err'; });
+ const req = { account: { username: 'test' } };
+
+ let err;
+ try {
+ await api.deleteBrew(req, res);
+ } catch (e) {
+ err = e;
+ }
+
+ expect(err).not.toBeUndefined();
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).not.toHaveBeenCalled();
+ expect(saveFunc).toHaveBeenCalled();
+ });
+
+ it('should delete google brew', async ()=>{
+ const brew = {
+ ...googleBrew,
+ authors : ['test']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ api.deleteGoogleBrew = jest.fn(async ()=>true);
+ const req = { account: { username: 'test' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).toHaveBeenCalled();
+ expect(api.deleteGoogleBrew).toHaveBeenCalled();
+ });
+
+ it('should handle google brew delete error', async ()=>{
+ const brew = {
+ ...googleBrew,
+ authors : ['test']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ api.deleteGoogleBrew = jest.fn(async ()=>{
+ throw 'err';
+ });
+ const req = { account: { username: 'test' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).toHaveBeenCalled();
+ expect(api.deleteGoogleBrew).toHaveBeenCalled();
+ });
+
+ it('should delete google brew and retain stub when multiple authors and owner request deletion', async ()=>{
+ const brew = {
+ ...googleBrew,
+ authors : ['test', 'test2']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ api.deleteGoogleBrew = jest.fn(async ()=>true);
+ const req = { account: { username: 'test' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(markModifiedFunc).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).not.toHaveBeenCalled();
+ expect(api.deleteGoogleBrew).toHaveBeenCalled();
+ expect(saveFunc).toHaveBeenCalled();
+ expect(saved.authors).toEqual(['test2']);
+ expect(saved.googleId).toEqual(undefined);
+ expect(saved.text).toEqual(undefined);
+ expect(saved.textBin).not.toEqual(undefined);
+ });
+
+ it('should retain google brew and update stub when multiple authors and extra author requests deletion', async ()=>{
+ const brew = {
+ ...googleBrew,
+ authors : ['test', 'test2']
+ };
+ api.getBrew = jest.fn(()=>async (req)=>{
+ req.brew = brew;
+ });
+ model.findOne = jest.fn(async ()=>modelBrew(brew));
+ api.deleteGoogleBrew = jest.fn(async ()=>true);
+ const req = { account: { username: 'test2' } };
+
+ await api.deleteBrew(req, res);
+
+ expect(api.getBrew).toHaveBeenCalled();
+ expect(model.findOne).toHaveBeenCalled();
+ expect(removeFunc).not.toHaveBeenCalled();
+ expect(api.deleteGoogleBrew).not.toHaveBeenCalled();
+ expect(saveFunc).toHaveBeenCalled();
+ expect(saved.authors).toEqual(['test']);
+ expect(saved.googleId).toEqual(brew.googleId);
+ });
+ });
+});
diff --git a/server/homebrew.model.js b/server/homebrew.model.js
index acc78a624..41f3b8716 100644
--- a/server/homebrew.model.js
+++ b/server/homebrew.model.js
@@ -6,17 +6,20 @@ const zlib = require('zlib');
const HomebrewSchema = mongoose.Schema({
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
editId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
+ googleId : { type: String },
title : { type: String, default: '' },
text : { type: String, default: '' },
textBin : { type: Buffer },
pageCount : { type: Number, default: 1 },
- description : { type: String, default: '' },
- tags : { type: String, default: '' },
- systems : [String],
- renderer : { type: String, default: '' },
- authors : [String],
- published : { type: Boolean, default: false },
+ description : { type: String, default: '' },
+ tags : [String],
+ systems : [String],
+ renderer : { type: String, default: '' },
+ authors : [String],
+ invitedAuthors : [String],
+ published : { type: Boolean, default: false },
+ thumbnail : { type: String, default: '' },
createdAt : { type: Date, default: Date.now },
updatedAt : { type: Date, default: Date.now },
@@ -36,28 +39,26 @@ HomebrewSchema.statics.increaseView = async function(query) {
return brew;
};
-HomebrewSchema.statics.get = function(query){
+HomebrewSchema.statics.get = function(query, fields=null){
return new Promise((resolve, reject)=>{
- Homebrew.find(query, (err, brews)=>{
+ Homebrew.find(query, fields, null, (err, brews)=>{
if(err || !brews.length) return reject('Can not find brew');
if(!_.isNil(brews[0].textBin)) { // Uncompress zipped text field
unzipped = zlib.inflateRawSync(brews[0].textBin);
brews[0].text = unzipped.toString();
}
- if(!brews[0].renderer)
- brews[0].renderer = 'legacy';
return resolve(brews[0]);
});
});
};
-HomebrewSchema.statics.getByUser = function(username, allowAccess=false){
+HomebrewSchema.statics.getByUser = function(username, allowAccess=false, fields=null){
return new Promise((resolve, reject)=>{
const query = { authors: username, published: true };
if(allowAccess){
delete query.published;
}
- Homebrew.find(query).lean().exec((err, brews)=>{ //lean() converts results to JSObjects
+ Homebrew.find(query, fields).lean().exec((err, brews)=>{ //lean() converts results to JSObjects
if(err) return reject('Can not find brew');
return resolve(brews);
});
diff --git a/server/middleware/check-client-version.js b/server/middleware/check-client-version.js
new file mode 100644
index 000000000..e9caf6eff
--- /dev/null
+++ b/server/middleware/check-client-version.js
@@ -0,0 +1,12 @@
+module.exports = (req, res, next)=>{
+ const userVersion = req.get('Homebrewery-Version');
+ const version = require('../../package.json').version;
+
+ if(userVersion != version) {
+ return res.status(412).send({
+ message : `Client version ${userVersion} is out of date. Please save your changes elsewhere and refresh to pick up client version ${version}.`
+ });
+ }
+
+ next();
+};
diff --git a/shared/naturalcrit/codeEditor/codeEditor.jsx b/shared/naturalcrit/codeEditor/codeEditor.jsx
index 42076ed76..245317910 100644
--- a/shared/naturalcrit/codeEditor/codeEditor.jsx
+++ b/shared/naturalcrit/codeEditor/codeEditor.jsx
@@ -30,6 +30,8 @@ if(typeof navigator !== 'undefined'){
// require('codemirror/addon/edit/trailingspace.js');
//Active line highlighting
// require('codemirror/addon/selection/active-line.js');
+ //Scroll past last line
+ require('codemirror/addon/scroll/scrollpastend.js');
//Auto-closing
//XML code folding is a requirement of the auto-closing tag feature and is not enabled
require('codemirror/addon/fold/xml-fold.js');
@@ -98,6 +100,7 @@ const CodeEditor = createClass({
indentWithTabs : true,
tabSize : 2,
historyEventDelay : 250,
+ scrollPastEnd : true,
extraKeys : {
'Ctrl-B' : this.makeBold,
'Cmd-B' : this.makeBold,
@@ -226,6 +229,15 @@ const CodeEditor = createClass({
this.codeMirror.replaceSelection('\n\\page\n\n', 'end');
},
+ injectText : function(injectText, overwrite=true) {
+ const cm = this.codeMirror;
+ if(!overwrite) {
+ cm.setCursor(cm.getCursor('from'));
+ }
+ cm.replaceSelection(injectText, 'end');
+ cm.focus();
+ },
+
makeUnderline : function() {
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 3) === '' && selection.slice(-4) === '';
this.codeMirror.replaceSelection(t ? selection.slice(3, -4) : `${selection}`, 'around');
@@ -352,12 +364,20 @@ const CodeEditor = createClass({
let text = '';
let currentLine = from.line;
const maxLength = 50;
+
+ let foldPreviewText = '';
while (currentLine <= to.line && text.length <= maxLength) {
- text += this.codeMirror.getLine(currentLine);
- if(currentLine < to.line)
- text += ' ';
- currentLine += 1;
+ const currentText = this.codeMirror.getLine(currentLine);
+ currentLine++;
+ if(currentText[0] == '#'){
+ foldPreviewText = currentText;
+ break;
+ }
+ if(!foldPreviewText && currentText != '\n') {
+ foldPreviewText = currentText;
+ }
}
+ text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`;
text = text.trim();
if(text.length > maxLength)
diff --git a/shared/naturalcrit/codeEditor/codeEditor.less b/shared/naturalcrit/codeEditor/codeEditor.less
index bf36293ed..1334299e4 100644
--- a/shared/naturalcrit/codeEditor/codeEditor.less
+++ b/shared/naturalcrit/codeEditor/codeEditor.less
@@ -3,11 +3,22 @@
@import (less) 'codemirror/addon/search/matchesonscrollbar.css';
@import (less) 'codemirror/addon/dialog/dialog.css';
+@keyframes sourceMoveAnimation {
+ 50% {background-color: red; color: white;}
+ 100% {background-color: unset; color: unset;}
+}
+
.codeEditor{
.CodeMirror-foldmarker {
font-family: inherit;
text-shadow: none;
font-weight: 600;
+ color: grey;
+}
+
+ .sourceMoveFlash .CodeMirror-line{
+ animation-name: sourceMoveAnimation;
+ animation-duration: 0.4s;
}
//.cm-tab {
@@ -19,4 +30,4 @@
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
// }
//}
-}
\ No newline at end of file
+}
diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js
index 607f7c428..8dbb1d7f9 100644
--- a/shared/naturalcrit/markdown.js
+++ b/shared/naturalcrit/markdown.js
@@ -32,7 +32,7 @@ const mustacheSpans = {
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
- const inlineRegex = /{{(?::(?:"[\w,\-()#%. ]*"|[\w\,\-()#%.]*)|[^"'{}\s])*\s*|}}/g;
+ const inlineRegex = /{{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1 *|}}/g;
const match = completeSpan.exec(src);
if(match) {
//Find closing delimiter
@@ -82,7 +82,7 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
- const blockRegex = /^ *{{(?::(?:"[\w,\-()#%. ]*"|[\w\,\-()#%.]*)|[^"'{}\s])* *$|^ *}}$/gm;
+ const blockRegex = /^ *{{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1 *$|^ *}}$/gm;
const match = completeBlock.exec(src);
if(match) {
//Find closing delimiter
@@ -130,7 +130,7 @@ const mustacheInjectInline = {
level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
- const inlineRegex = /^ *{((?::(?:"[\w,\-()#%. ]*"|[\w\,\-()#%.]*)|[^"'{}\s])*)}/g;
+ const inlineRegex = /^ *{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src);
if(match) {
const lastToken = tokens[tokens.length - 1];
@@ -165,7 +165,7 @@ const mustacheInjectBlock = {
level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
- const inlineRegex = /^ *{((?::(?:"[\w,\-()#%. ]*"|[\w\,\-()#%.]*)|[^"'{}\s])*)}/ym;
+ const inlineRegex = /^ *{(?=((?::(?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src);
if(match) {
const lastToken = tokens[tokens.length - 1];
diff --git a/shared/naturalcrit/nav/nav.jsx b/shared/naturalcrit/nav/nav.jsx
index fde42a939..ef7d387e9 100644
--- a/shared/naturalcrit/nav/nav.jsx
+++ b/shared/naturalcrit/nav/nav.jsx
@@ -73,18 +73,35 @@ const Nav = {
dropdown : createClass({
displayName : 'Nav.dropdown',
+ getDefaultProps : function() {
+ return {
+ trigger : 'hover'
+ };
+ },
getInitialState : function() {
return {
showDropdown : false
};
},
-
+ componentDidMount : function() {
+ if(this.props.trigger == 'click')
+ document.addEventListener('click', this.handleClickOutside);
+ },
+ componentWillUnmount : function() {
+ if(this.props.trigger == 'click')
+ document.removeEventListener('click', this.handleClickOutside);
+ },
+ handleClickOutside : function(e){
+ // Close dropdown when clicked outside
+ if(this.refs.dropdown && !this.refs.dropdown.contains(e.target)) {
+ this.handleDropdown(false);
+ }
+ },
handleDropdown : function(show){
this.setState({
showDropdown : show
});
},
-
renderDropdown : function(dropdownChildren){
if(!this.state.showDropdown) return null;
@@ -94,7 +111,6 @@ const Nav = {