From d0a519936e74bba48db66848bd719a2986b6c356 Mon Sep 17 00:00:00 2001 From: Rowan Manning Date: Wed, 9 Dec 2020 00:20:08 +0000 Subject: [PATCH] Add a hidden demo option to allow public demos (#19) --- README.md | 20 +++++++++ client/sass/components/_notification.scss | 14 ++++++- server/controller/feeds-by-id.js | 7 ++++ server/controller/settings.js | 4 ++ server/controller/subscribe.js | 4 ++ server/lib/app.js | 50 ++++++++++++++++++----- server/lib/demo-error.js | 9 ++++ server/middleware/require-auth.js | 3 ++ server/model/settings.js | 4 ++ server/view/layout/main.js | 9 ++++ test/integration/seed/settings/index.js | 3 +- test/integration/setup.test.js | 19 +++++++-- 12 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 server/lib/demo-error.js diff --git a/README.md b/README.md index e44bfb6..5fb7231 100755 --- a/README.md +++ b/README.md @@ -121,6 +121,26 @@ This application is configured using environment variables, or an [`.env` file]( You can also change more configurations through the settings page of a running copy of Audrey, these additional configurations are stored in the database. +### Demo mode + +**Do not use demo mode on your main feed reader – it will result in loss of data**. Audrey can be placed into "demo mode" by using setting a hidden config option in the database. It's important to note that placing an installation in demo mode will do the following things: + + - You will no longer need to log in with a password, all data (including settings) will be viewable by anyone who visits the site + + - You will no longer be able to change site settings or feed settings + + - You will no longer be able to subscribe to new feeds + + - Every 15 minutes, all entries in the database will be removed and all feeds will be refreshed + +These changes are designed to allow Audrey to be publicly viewable without the risk of unwanted data being added to your database. + +The only way to enable demo mode is to manually change the settings in your database. You can do so using the following while connected to MongoDB: + +```js +db.settings.updateMany({}, {$set: {demoMode: true}}) +``` + ## Beta notice diff --git a/client/sass/components/_notification.scss b/client/sass/components/_notification.scss index f9a4c68..9159235 100644 --- a/client/sass/components/_notification.scss +++ b/client/sass/components/_notification.scss @@ -59,4 +59,16 @@ .notification--success { --notification-border-color: var(--colorvalue-green); --notification-background-color: var(--colorvalue-green-light); -} \ No newline at end of file +} + +// Notification block, "demo" modifier +.notification--demo { + --notification-border-color: var(--colorvalue-yellow-dark); + --notification-background-color: var(--colorvalue-yellow); + border-bottom: calc(var(--spacing-rule) * 0.25) solid var(--notification-border-color); + border-left: none; + font-size: var(--font-size-small); + line-height: var(--font-size-body); + margin-bottom: var(--spacing-half-line) !important; + padding: var(--spacing-rule); +} diff --git a/server/controller/feeds-by-id.js b/server/controller/feeds-by-id.js index 2f44b03..fea7930 100755 --- a/server/controller/feeds-by-id.js +++ b/server/controller/feeds-by-id.js @@ -1,5 +1,6 @@ 'use strict'; +const demoError = require('../lib/demo-error'); const paginate = require('../middleware/paginate'); const render = require('../middleware/render'); const requireAuth = require('../middleware/require-auth'); @@ -121,6 +122,9 @@ module.exports = function mountFeedsByIdController(app) { try { // On POST, attempt to save the feed if (request.method === 'POST') { + if (request.settings.demoMode) { + throw demoError(); + } // We use a fresh feed object so that we don't interfere // with displayed properties outside of the form @@ -156,6 +160,9 @@ module.exports = function mountFeedsByIdController(app) { try { // On POST, attempt to unsubscribe from the feed if (request.method === 'POST') { + if (request.settings.demoMode) { + throw demoError(); + } if (unsubscribeForm.data.confirm) { const title = request.feed.displayTitle; await request.feed.unsubscribe(); diff --git a/server/controller/settings.js b/server/controller/settings.js index 643150c..b638d51 100755 --- a/server/controller/settings.js +++ b/server/controller/settings.js @@ -1,5 +1,6 @@ 'use strict'; +const demoError = require('../lib/demo-error'); const render = require('../middleware/render'); const requireAuth = require('../middleware/require-auth'); const {ValidationError} = require('@rowanmanning/app'); @@ -55,6 +56,9 @@ module.exports = function mountSettingsController(app) { try { // On POST, attempt to save the settings if (request.method === 'POST') { + if (request.settings.demoMode) { + throw demoError(); + } // We use a fresh settings object so that we don't interfere // with displayed properties outside of the form diff --git a/server/controller/subscribe.js b/server/controller/subscribe.js index b52ce3e..7914c2f 100755 --- a/server/controller/subscribe.js +++ b/server/controller/subscribe.js @@ -1,5 +1,6 @@ 'use strict'; +const demoError = require('../lib/demo-error'); const render = require('../middleware/render'); const requireAuth = require('../middleware/require-auth'); const {ValidationError} = require('@rowanmanning/app'); @@ -35,6 +36,9 @@ module.exports = function mountSubscribeController(app) { try { // On POST, attempt to create a feed if (request.method === 'POST') { + if (request.settings.demoMode) { + throw demoError(); + } const feed = await Feed.subscribe(subscribeForm.data.xmlUrl); await feed.refresh(); request.flash('subscribed', true); diff --git a/server/lib/app.js b/server/lib/app.js index faf39e7..6580a25 100644 --- a/server/lib/app.js +++ b/server/lib/app.js @@ -63,23 +63,51 @@ module.exports = class AudreyApp extends App { super.setupControllers(); } - setupScheduledJobs() { + async setupScheduledJobs() { try { - this.scheduledJob = schedule.scheduleJob(this.options.updateSchedule, async () => { - try { - await this.models.Entry.performScheduledJobs(); - await this.models.Feed.performScheduledJobs(); - this.log.info(`[sheduler]: scheduled jobs complete`); - } catch (error) { - this.log.error(`[sheduler]: scheduled jobs failed: ${error.message}`); - } - }); - this.log.info(`[setup:sheduler]: started with cron "${this.options.updateSchedule}"`); + const settings = await this.models.Settings.get(); + + // If the app is in demo mode, wipe the entries every 15 minutes + // and refresh all of the feeds so that entries are freshly loaded + if (settings.demoMode) { + this.setupScheduledDemoJobs(); + + // Otherwise refresh feeds on a schedule + } else { + this.setupScheduledStandardJobs(); + } + } catch (error) { this.log.error(`[setup:sheduler]: setup failed, ${error.message}`); } } + setupScheduledStandardJobs() { + this.scheduledJob = schedule.scheduleJob(this.options.updateSchedule, async () => { + try { + await this.models.Entry.performScheduledJobs(); + await this.models.Feed.performScheduledJobs(); + this.log.info(`[sheduler]: scheduled jobs complete`); + } catch (error) { + this.log.error(`[sheduler]: scheduled jobs failed: ${error.message}`); + } + }); + this.log.info(`[setup:sheduler]: started with cron "${this.options.updateSchedule}"`); + } + + setupScheduledDemoJobs() { + this.scheduledJob = schedule.scheduleJob('0,15,30,45 * * * *', async () => { + try { + await this.models.Entry.deleteMany({}); + await this.models.Feed.refreshAll(); + this.log.info(`[sheduler]: scheduled jobs complete`); + } catch (error) { + this.log.error(`[sheduler]: scheduled jobs failed: ${error.message}`); + } + }); + this.log.info(`[setup:sheduler]: started in demo mode, resetting entries every 15 minutes`); + } + teardown() { if (this.scheduledJob) { this.scheduledJob.cancel(); diff --git a/server/lib/demo-error.js b/server/lib/demo-error.js new file mode 100644 index 0000000..c5a7f29 --- /dev/null +++ b/server/lib/demo-error.js @@ -0,0 +1,9 @@ +'use strict'; + +const {ValidationError} = require('@rowanmanning/app'); + +module.exports = function demoError(message) { + const error = new ValidationError(); + error.errors.demo = new Error(message || 'Sorry, this feature is not available in demo mode.'); + return error; +}; diff --git a/server/middleware/require-auth.js b/server/middleware/require-auth.js index 650666a..1bf37d8 100755 --- a/server/middleware/require-auth.js +++ b/server/middleware/require-auth.js @@ -9,6 +9,9 @@ */ module.exports = function requireAuth() { return (request, response, next) => { + if (request.settings.demoMode) { + return next(); + } if (!request.settings.hasPassword()) { return response.redirect('/'); } diff --git a/server/model/settings.js b/server/model/settings.js index 5fca224..cb2d2d9 100644 --- a/server/model/settings.js +++ b/server/model/settings.js @@ -48,6 +48,10 @@ module.exports = function defineSettingsSchema(app) { passwordHash: { type: String, minlength: [8, 'Password must be 8 or more characters in length'] + }, + demoMode: { + type: Boolean, + default: false } }, { timestamps: true, diff --git a/server/view/layout/main.js b/server/view/layout/main.js index e10d067..8b9a165 100755 --- a/server/view/layout/main.js +++ b/server/view/layout/main.js @@ -18,6 +18,15 @@ const Main = require('../partial/section/main'); */ module.exports = function renderMainLayout(context, content) { return layout(context, html` + ${context.settings.demoMode ? html` +
+

+ This app is running in demo mode, any changes will be reset + every 15 minutes. Some features have been disabled for the demo to prevent + inappropriate content from being displayed. +

+
+ ` : ''} <${Header} title=${context.settings.siteTitle} currentPath=${context.currentPath} diff --git a/test/integration/seed/settings/index.js b/test/integration/seed/settings/index.js index 48ad118..7832181 100755 --- a/test/integration/seed/settings/index.js +++ b/test/integration/seed/settings/index.js @@ -11,7 +11,8 @@ module.exports = async function seedDatabase(models) { daysToRetainOldEntries: 60, autoMarkAsRead: true, showHelpText: true, - passwordHash: 'password' + passwordHash: 'password', + demoMode: false }); }; diff --git a/test/integration/setup.test.js b/test/integration/setup.test.js index 5b4cdc1..4ec3434 100755 --- a/test/integration/setup.test.js +++ b/test/integration/setup.test.js @@ -31,10 +31,21 @@ before(done => { done(error); }); - // Once the app starts, we're ready to test - global.app.once('server:started', () => { - done(); - }); + // Once the app starts and the database is connected, we're ready to test + let isReady = false; + async function handleReadyEvent() { + if (isReady) { + + // This makes sure that settings are created before + // any requests are made + await global.app.models.Settings.get(); + + return done(); + } + isReady = true; + } + global.app.once('server:started', handleReadyEvent); + global.app.once('database:connected', handleReadyEvent); // Start the application global.app.setup();