From c55f6b9422b50b7ebf831d343217243a160555dc Mon Sep 17 00:00:00 2001 From: ItzNotABug Date: Mon, 3 Jun 2024 12:44:58 +0530 Subject: [PATCH] feat: add workflow for PRs. --- .dockerignore | 6 ++ .github/workflows/source-ci.yaml | 26 +++++ .prettierignore | 4 + app.js | 4 +- package-lock.json | 16 +++ package.json | 9 +- routes/analytics.js | 4 +- routes/index.js | 12 ++- routes/login.js | 5 +- routes/logs.js | 2 +- routes/newsletters.js | 41 +++++--- routes/password.js | 3 +- routes/preview.js | 2 +- routes/published.js | 28 +++-- routes/settings.js | 26 +++-- routes/track.js | 8 +- tailwind.config.js | 10 +- utils/api/ghost.js | 119 +++++++++++++++------- utils/bitset.js | 5 +- utils/data/configs.js | 169 ++++++++++++++++++++++--------- utils/data/files.js | 48 +++++---- utils/data/ops/emails_queue.js | 12 ++- utils/data/ops/links_queue.js | 15 +-- utils/log/logger.js | 2 +- utils/log/options.js | 14 ++- utils/mail/mailer.js | 156 +++++++++++++++++++++------- utils/misc.js | 101 +++++++++++++----- utils/models/post.js | 14 +-- utils/models/stats.js | 5 +- utils/models/subscriber.js | 23 ++++- utils/newsletter.js | 151 +++++++++++++++++---------- utils/widgets.js | 135 ++++++++++++++++-------- 32 files changed, 821 insertions(+), 354 deletions(-) create mode 100644 .github/workflows/source-ci.yaml create mode 100644 .prettierignore diff --git a/.dockerignore b/.dockerignore index 8f46309..8b56242 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,12 @@ files/ .git .gitignore +# prettier +.prettierignore + +# ci +.github + # markdown LICENSE.md README.md diff --git a/.github/workflows/source-ci.yaml b/.github/workflows/source-ci.yaml new file mode 100644 index 0000000..a2e4058 --- /dev/null +++ b/.github/workflows/source-ci.yaml @@ -0,0 +1,26 @@ +name: Source CI + +on: + workflow_dispatch: # for manual runs + pull_request: + types: [ opened, synchronize ] + +jobs: + build-and-test: + name: Run Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install Dependencies + run: npm install + + - name: Run Prettier + run: npm run lint \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..14ce980 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +*.json +**/*.md +**/*.css +**/*.yaml \ No newline at end of file diff --git a/app.js b/app.js index eb4e377..43c2d54 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ import express from 'express'; import Miscellaneous from './utils/misc.js'; import ProjectConfigs from './utils/data/configs.js'; -import {logDebug, logTags} from './utils/log/logger.js'; +import { logDebug, logTags } from './utils/log/logger.js'; // route imports import logs from './routes/logs.js'; @@ -37,7 +37,7 @@ expressApp.use((req, res) => res.status(404).render('errors/404')); logDebug(logTags.Express, 'Routes configured!'); // start the app with given port! -ProjectConfigs.ghosler().then(configs => { +ProjectConfigs.ghosler().then((configs) => { expressApp.listen(configs.port); logDebug(logTags.Express, 'App started successfully!'); logDebug(logTags.Express, '============================'); diff --git a/package-lock.json b/package-lock.json index b211384..21e6aec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/cheerio": "^0.22.35", "@types/express-fileupload": "^1.5.0", "nodemon": "^3.1.0", + "prettier": "^3.3.0", "tailwindcss": "^3.4.1" } }, @@ -2446,6 +2447,21 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/prettier": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.0.tgz", + "integrity": "sha512-J9odKxERhCQ10OC2yb93583f6UnYutOeiV5i0zEDS7UGTdUt0u+y8erxl3lBKvwo/JHyyoEdXjwp4dke9oyZ/g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/probe-image-size": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", diff --git a/package.json b/package.json index aef7a1b..e2922b2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,13 @@ "main": "app.js", "type": "module", "author": "@itznotabug", + "prettier": { + "tabWidth": 4, + "singleQuote": true + }, "scripts": { + "lint": "prettier . --check", + "format": "prettier . --write", "dev": "nodemon -e js,ejs --ignore custom-template.ejs app.js", "cleanstart": "npm run buildcss && npm run dev", "buildcss": "npx tailwindcss -i ./public/styles/tailwind.css -o ./public/styles/style.css --minify" @@ -28,9 +34,10 @@ "winston": "^3.13.0" }, "devDependencies": { - "@types/cheerio": "^0.22.35", "nodemon": "^3.1.0", + "prettier": "^3.3.0", "tailwindcss": "^3.4.1", + "@types/cheerio": "^0.22.35", "@types/express-fileupload": "^1.5.0" } } diff --git a/routes/analytics.js b/routes/analytics.js index 94ac297..8827046 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -18,7 +18,7 @@ router.get('/details/:postId', async (req, res) => { const postId = req.params.postId; const post = await Files.get(postId); const postSentiments = await new Ghost().postSentiments(postId); - res.render('dashboard/details', {post, postSentiments}); + res.render('dashboard/details', { post, postSentiments }); }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/index.js b/routes/index.js index 6f3091e..4713a93 100644 --- a/routes/index.js +++ b/routes/index.js @@ -7,24 +7,26 @@ const router = express.Router(); router.get('/', async (_, res) => { const configs = await ProjectConfigs.all(); - if (configs.ghosler.auth.user === 'ghosler' && + if ( + configs.ghosler.auth.user === 'ghosler' && configs.ghosler.auth.pass === Miscellaneous.hash('admin') ) { res.render('index', { level: 'error', - message: 'Update your username and password.

Default -
Username: ghosler, Password: admin' + message: + 'Update your username and password.

Default -
Username: ghosler, Password: admin', }); } else if (configs.ghost.url === '' || configs.ghost.key === '') { res.render('index', { level: 'error', - message: 'Set up your Ghost Site Url & add an Admin API Key.' + message: 'Set up your Ghost Site Url & add an Admin API Key.', }); } else if (configs.mail.length === 0) { res.render('index', { level: 'error', - message: 'Add email credentials to send newsletters.' + message: 'Add email credentials to send newsletters.', }); } else res.render('index'); }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/login.js b/routes/login.js index bb92a43..8e8ac52 100644 --- a/routes/login.js +++ b/routes/login.js @@ -4,7 +4,7 @@ import Miscellaneous from '../utils/misc.js'; const router = express.Router(); router.get('/login', (req, res) => { - res.render('login', {redirect: req.query.redirect ?? ''}); + res.render('login', { redirect: req.query.redirect ?? '' }); }); router.post('/login', async (req, res) => { @@ -22,5 +22,4 @@ router.get('/logout', (req, res) => { res.redirect('/'); }); - -export default router; \ No newline at end of file +export default router; diff --git a/routes/logs.js b/routes/logs.js index f7b5602..cdd1736 100644 --- a/routes/logs.js +++ b/routes/logs.js @@ -16,4 +16,4 @@ router.get('/clear/:type', async (req, res) => { res.redirect(`/logs/${logType}`); }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/newsletters.js b/routes/newsletters.js index 04d2949..a4edb20 100644 --- a/routes/newsletters.js +++ b/routes/newsletters.js @@ -15,11 +15,15 @@ router.get('/:postId', async (req, res) => { if (!postObject) { return res.render('dashboard/newsletters', { level: 'error', - message: 'Invalid Post Id!' + message: 'Invalid Post Id!', }); } - if (postObject && postObject.stats && postObject.stats.newsletterStatus === 'Unsent') { + if ( + postObject && + postObject.stats && + postObject.stats.newsletterStatus === 'Unsent' + ) { const newsletterItems = await new Ghost().newsletters(); delete newsletterItems.meta; // we don't need meta here. @@ -28,12 +32,12 @@ router.get('/:postId', async (req, res) => { res.render('dashboard/newsletters', { post: postObject, - newsletters: newsletters + newsletters: newsletters, }); } else { res.render('dashboard/newsletters', { level: 'error', - message: 'This post is already sent as a newsletter via email.' + message: 'This post is already sent as a newsletter via email.', }); } }); @@ -47,7 +51,7 @@ router.post('/send', async (req, res) => { if (!postId || !newsletterId || !newsletterName) { return res.render('dashboard/newsletters', { level: 'error', - message: 'Post Id, Newsletter Id or Newsletter Name is missing!' + message: 'Post Id, Newsletter Id or Newsletter Name is missing!', }); } @@ -55,7 +59,7 @@ router.post('/send', async (req, res) => { if (!postObject) { return res.render('dashboard/newsletters', { level: 'error', - message: 'Invalid Post Id!' + message: 'Invalid Post Id!', }); } @@ -72,7 +76,12 @@ router.post('/send', async (req, res) => { * 3. Post contains stats object & * 4. Post's Stats newsletter status is 'Unsent'. */ - if (post && post.content && post.stats && post.stats.newsletterStatus === 'Unsent') { + if ( + post && + post.content && + post.stats && + post.stats.newsletterStatus === 'Unsent' + ) { /** * Mark the post's current status as 'Sending' * This is done to prevent re-sending until Ghosler fetches members. @@ -82,20 +91,26 @@ router.post('/send', async (req, res) => { await post.update(true); // send the newsletter as usual. - Newsletter.send(post, {id: newsletterId, name: newsletterName}).then(); + Newsletter.send(post, { + id: newsletterId, + name: newsletterName, + }).then(); res.render('dashboard/newsletters', { post: post, level: 'success', - message: 'Newsletter will be sent shortly.' + message: 'Newsletter will be sent shortly.', }); - } else { let message = 'This post is already sent as a newsletter via email.'; - if (!post || !post.content || !post.stats) message = 'Post does not seem to be valid.'; + if (!post || !post.content || !post.stats) + message = 'Post does not seem to be valid.'; - res.render('dashboard/newsletters', {level: 'error', message: message}); + res.render('dashboard/newsletters', { + level: 'error', + message: message, + }); } }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/password.js b/routes/password.js index 24cbbf7..206cf0c 100644 --- a/routes/password.js +++ b/routes/password.js @@ -13,5 +13,4 @@ router.post('/', async (req, res) => { res.render('dashboard/password', result); }); - -export default router; \ No newline at end of file +export default router; diff --git a/routes/preview.js b/routes/preview.js index 1744531..cd5bd89 100644 --- a/routes/preview.js +++ b/routes/preview.js @@ -9,4 +9,4 @@ router.get('/', async (_, res) => { res.set('Content-Type', 'text/html').send(template.modifiedHtml); }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/published.js b/routes/published.js index 2e9f77a..0134b9e 100644 --- a/routes/published.js +++ b/routes/published.js @@ -3,24 +3,28 @@ import Ghost from '../utils/api/ghost.js'; import Post from '../utils/models/post.js'; import Miscellaneous from '../utils/misc.js'; import Newsletter from '../utils/newsletter.js'; -import {logDebug, logTags} from '../utils/log/logger.js'; +import { logDebug, logTags } from '../utils/log/logger.js'; const router = express.Router(); router.post('/', async (req, res) => { if (!req.body || !req.body.post || !req.body.post.current) { - return res.status(400).json({message: 'Post content seems to be missing!'}); + return res + .status(400) + .json({ message: 'Post content seems to be missing!' }); } // check if the request is authenticated. const isSecure = await Miscellaneous.isPostSecure(req); if (!isSecure) { - return res.status(401).json({message: 'Invalid Authorization.'}); + return res.status(401).json({ message: 'Invalid Authorization.' }); } // check if contains the ignore tag. if (Post.containsIgnoreTag(req.body)) { - return res.status(200).json({message: 'Post contains `ghosler_ignore` tag, ignoring.'}); + return res + .status(200) + .json({ message: 'Post contains `ghosler_ignore` tag, ignoring.' }); } logDebug(logTags.Newsletter, 'Post received via webhook.'); @@ -30,16 +34,24 @@ router.post('/', async (req, res) => { const created = await post.save(newslettersCount > 1); if (!created) { - res.status(500).json({message: 'The post data could not be saved, or emails for this post have already been sent.'}); + res.status(500).json({ + message: + 'The post data could not be saved, or emails for this post have already been sent.', + }); } else { if (newslettersCount === 1) { Newsletter.send(post).then(); - res.status(200).json({message: 'Newsletter will be sent shortly.'}); + res.status(200).json({ + message: 'Newsletter will be sent shortly.', + }); } else { // we probably have multiple active newsletters or none at all, so just save the post. - res.status(200).json({message: 'Multiple or No active Newsletters found, current Post saved for manual action.'}); + res.status(200).json({ + message: + 'Multiple or No active Newsletters found, current Post saved for manual action.', + }); } } }); -export default router; \ No newline at end of file +export default router; diff --git a/routes/settings.js b/routes/settings.js index 860937c..697c0a6 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -9,19 +9,20 @@ const router = express.Router(); router.get('/', async (_, res) => { const configs = await ProjectConfigs.all(); - res.render('dashboard/settings', {configs: configs}); + res.render('dashboard/settings', { configs: configs }); }); router.get('/template', async (_, res) => { const customTemplateExists = await Files.customTemplateExists(); - res.render('dashboard/upload-template', {customTemplateExists}); + res.render('dashboard/upload-template', { customTemplateExists }); }); router.get('/template/download/:type', async (req, res) => { const downloadType = req.params.type ?? 'base'; // default is base. let newsletterFilePath = path.join(process.cwd(), '/views/newsletter.ejs'); - if (downloadType === 'custom_template') newsletterFilePath = Files.customTemplatePath(); + if (downloadType === 'custom_template') + newsletterFilePath = Files.customTemplatePath(); const newsletterFile = fs.readFileSync(newsletterFilePath); res.setHeader('Content-Type', 'text/plain'); @@ -31,16 +32,22 @@ router.get('/template/download/:type', async (req, res) => { router.post('/template', async (req, res) => { const customTemplateFile = req.files['custom_template.file']; - // noinspection JSCheckFunctionSignatures - const {level, message} = await ProjectConfigs.updateCustomTemplate(customTemplateFile); + const { level, message } = + await ProjectConfigs.updateCustomTemplate(customTemplateFile); const customTemplateExists = await Files.customTemplateExists(); - res.render('dashboard/upload-template', {level, message, customTemplateExists}); + res.render('dashboard/upload-template', { + level, + message, + customTemplateExists, + }); }); router.post('/', async (req, res) => { const formData = req.body; - let fullUrl = new URL(`${req.protocol}://${req.get('Host')}${req.originalUrl}`); + let fullUrl = new URL( + `${req.protocol}://${req.get('Host')}${req.originalUrl}`, + ); if (req.get('Referer')) { fullUrl = new URL(req.get('Referer')); } @@ -53,7 +60,7 @@ router.post('/', async (req, res) => { const result = await ProjectConfigs.update(formData); const configs = await ProjectConfigs.all(); - let {level, message} = result; + let { level, message } = result; if (configs.ghost.url && configs.ghost.key) { const ghost = new Ghost(); @@ -69,8 +76,7 @@ router.post('/', async (req, res) => { } } - res.render('dashboard/settings', {level, message, configs}); + res.render('dashboard/settings', { level, message, configs }); }); - export default router; diff --git a/routes/track.js b/routes/track.js index f3fabc9..955005b 100644 --- a/routes/track.js +++ b/routes/track.js @@ -18,8 +18,8 @@ router.get('/track/pixel.png', async (req, res) => { 'Content-Type': 'image/png', 'Content-Length': pixel.length, 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' + Pragma: 'no-cache', + Expires: '0', }); res.end(pixel); @@ -32,8 +32,8 @@ router.get('/track/link', async (req, res) => { res.redirect(req.query.redirect); } else { // redirect to main ghost blog. - ProjectConfigs.ghost().then(cfg => res.redirect(cfg.url)); + ProjectConfigs.ghost().then((cfg) => res.redirect(cfg.url)); } }); -export default router; \ No newline at end of file +export default router; diff --git a/tailwind.config.js b/tailwind.config.js index a635fda..94480f3 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,8 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./views/**/*.{html,js,ejs}"], - theme: { - extend: {}, - }, - plugins: [], + content: ['./views/**/*.{html,js,ejs}'], + theme: { + extend: {}, + }, + plugins: [], }; diff --git a/utils/api/ghost.js b/utils/api/ghost.js index e405695..104ecb2 100644 --- a/utils/api/ghost.js +++ b/utils/api/ghost.js @@ -2,13 +2,12 @@ import Miscellaneous from '../misc.js'; import GhostAdminAPI from '@tryghost/admin-api'; import ProjectConfigs from '../data/configs.js'; import Subscriber from '../models/subscriber.js'; -import {logError, logTags, logToConsole} from '../log/logger.js'; +import { logError, logTags, logToConsole } from '../log/logger.js'; /** * Class that handles api calls with Ghost's Admin APIs. */ export default class Ghost { - /** * A hardcoded 'Generic' newsletter type. * @@ -19,7 +18,8 @@ export default class Ghost { static genericNewsletterItem = { id: '6de8d1d3d29a03060e1c4fa745e0eba7', name: 'Generic', - description: 'This option sends the post to all users, irrespective of their subscribed newsletter.' + description: + 'This option sends the post to all users, irrespective of their subscribed newsletter.', }; /** @@ -67,16 +67,17 @@ export default class Ghost { try { const postInfo = await ghost.posts.read({ id: postId, - include: 'sentiment,count.positive_feedback,count.negative_feedback' + include: + 'sentiment,count.positive_feedback,count.negative_feedback', }); - const {count, sentiment} = postInfo; - const {negative_feedback, positive_feedback} = count; + const { count, sentiment } = postInfo; + const { negative_feedback, positive_feedback } = count; return { sentiment, negative_feedback, - positive_feedback + positive_feedback, }; } catch (error) { if (error.name !== 'NotFoundError') logError(logTags.Ghost, error); @@ -87,7 +88,7 @@ export default class Ghost { return { sentiment: 0, negative_feedback: 0, - positive_feedback: 0 + positive_feedback: 0, }; } } @@ -107,10 +108,9 @@ export default class Ghost { while (true) { const registeredMembers = await ghost.members.browse({ - page: page, - filter: 'subscribed:true' - } - ); + page: page, + filter: 'subscribed:true', + }); subscribedMembers.push(...registeredMembers); @@ -123,7 +123,8 @@ export default class Ghost { return subscribedMembers.reduce((activeSubscribers, member) => { const subscriber = Subscriber.make(member); - if (subscriber.isSubscribedTo(newsletterId)) activeSubscribers.push(subscriber); + if (subscriber.isSubscribedTo(newsletterId)) + activeSubscribers.push(subscriber); return activeSubscribers; }, []); } @@ -142,13 +143,23 @@ export default class Ghost { // supposed to be an array of objects but lets check anyway! if (Array.isArray(settings)) { const commentsSettingsKey = 'comments_enabled'; - const settingObject = settings.filter(obj => obj.key === commentsSettingsKey)[0]; - const commentsEnabled = settingObject ? settingObject.value !== 'off' : true; - logToConsole(logTags.Ghost, `Site comments enabled: ${commentsEnabled}`); + const settingObject = settings.filter( + (obj) => obj.key === commentsSettingsKey, + )[0]; + const commentsEnabled = settingObject + ? settingObject.value !== 'off' + : true; + logToConsole( + logTags.Ghost, + `Site comments enabled: ${commentsEnabled}`, + ); return commentsEnabled; } else { // no idea about this unknown structure, return a default value! - logToConsole(logTags.Ghost, 'Could not check if the site has comments enabled, defaulting to true'); + logToConsole( + logTags.Ghost, + 'Could not check if the site has comments enabled, defaulting to true', + ); return true; } } catch (error) { @@ -167,8 +178,9 @@ export default class Ghost { const ghost = await this.#ghost(); return await ghost.posts.browse({ filter: `status:published+id:-${currentPostId}`, - order: 'published_at DESC', limit: 3, - fields: 'title, custom_excerpt, excerpt, url, feature_image' + order: 'published_at DESC', + limit: 3, + fields: 'title, custom_excerpt, excerpt, url, feature_image', }); } @@ -182,13 +194,17 @@ export default class Ghost { async registerWebhook() { const ghosler = await ProjectConfigs.ghosler(); if (ghosler.url === '' || ghosler.url.includes('localhost')) { - return {level: 'warning', message: 'Ignore webhook check.'}; + return { level: 'warning', message: 'Ignore webhook check.' }; } const ghost = await this.#ghost(); const secret = (await ProjectConfigs.ghost()).secret; if (!secret || secret === '') { - return {level: 'error', message: 'Secret is not set or empty or is less than 8 characters.'}; + return { + level: 'error', + message: + 'Secret is not set or empty or is less than 8 characters.', + }; } try { @@ -199,16 +215,31 @@ export default class Ghost { secret: secret, }); - return {level: 'success', message: 'Webhook created successfully.'}; + return { + level: 'success', + message: 'Webhook created successfully.', + }; } catch (error) { const context = error.context; if (error.name === 'UnauthorizedError') { - return {level: 'error', message: 'Unable to check for Webhook, Ghost Admin API not valid.'}; - } else if (context === 'Target URL has already been used for this event.') { - return {level: 'success', message: 'Webhook exists for this API Key.'}; + return { + level: 'error', + message: + 'Unable to check for Webhook, Ghost Admin API not valid.', + }; + } else if ( + context === 'Target URL has already been used for this event.' + ) { + return { + level: 'success', + message: 'Webhook exists for this API Key.', + }; } else { logError(logTags.Ghost, error); - return {level: 'error', message: 'Webhook creation failed, see error logs.'}; + return { + level: 'error', + message: 'Webhook creation failed, see error logs.', + }; } } } @@ -223,7 +254,7 @@ export default class Ghost { async registerIgnoreTag() { const ghosler = await ProjectConfigs.ghosler(); if (ghosler.url === '' || ghosler.url.includes('localhost')) { - return {level: 'warning', message: 'Ignore tag check.'}; + return { level: 'warning', message: 'Ignore tag check.' }; } try { @@ -232,20 +263,31 @@ export default class Ghost { // check if one already exists with given slug. const exists = await this.#ignoreTagExists(ghost, ignoreTagSlug); - if (exists) return {level: 'success', message: 'Ghosler ignore tag already exists.'}; + if (exists) + return { + level: 'success', + message: 'Ghosler ignore tag already exists.', + }; await ghost.tags.add({ slug: ignoreTagSlug, name: '#GhoslerIgnore', visibility: 'internal', // using # anyway makes it internal. accent_color: '#0f0f0f', - description: 'Any post using this tag will be ignore by Ghosler & will not be sent as a newsletter email.' + description: + 'Any post using this tag will be ignore by Ghosler & will not be sent as a newsletter email.', }); - return {level: 'success', message: 'Ignore tag created successfully.'}; + return { + level: 'success', + message: 'Ignore tag created successfully.', + }; } catch (error) { logError(logTags.Ghost, error); - return {level: 'error', message: 'Ignore tag creation failed, see error logs.'}; + return { + level: 'error', + message: 'Ignore tag creation failed, see error logs.', + }; } } @@ -259,7 +301,7 @@ export default class Ghost { */ async #ignoreTagExists(ghost, tagSlug) { try { - await ghost.tags.read({slug: tagSlug}); + await ghost.tags.read({ slug: tagSlug }); return true; } catch (error) { return false; @@ -277,9 +319,14 @@ export default class Ghost { async #settings() { const ghost = await ProjectConfigs.ghost(); let token = `Ghost ${Miscellaneous.ghostToken(ghost.key, '/admin/')}`; - const ghostHeaders = {Authorization: token, 'User-Agent': 'GhostAdminSDK/1.13.11'}; + const ghostHeaders = { + Authorization: token, + 'User-Agent': 'GhostAdminSDK/1.13.11', + }; - const response = await fetch(`${ghost.url}/ghost/api/admin/settings`, {headers: ghostHeaders}); + const response = await fetch(`${ghost.url}/ghost/api/admin/settings`, { + headers: ghostHeaders, + }); if (!response.ok) { // will be caught by the calling function anyway. throw new Error(`HTTP error! status: ${response.status}`); @@ -295,7 +342,7 @@ export default class Ghost { return new GhostAdminAPI({ url: ghost.url, key: ghost.key, - version: ghost.version + version: ghost.version, }); } -} \ No newline at end of file +} diff --git a/utils/bitset.js b/utils/bitset.js index 90537bb..ed7faa0 100644 --- a/utils/bitset.js +++ b/utils/bitset.js @@ -2,7 +2,6 @@ * Represents a BitSet, a data structure to handle a set of bits (0s and 1s). */ export default class BitSet { - /** * Creates a BitSet. * @@ -24,7 +23,7 @@ export default class BitSet { * @returns {number} The count of bits set to 1. */ popCount() { - return this.bits.filter(b => b === 1).length; + return this.bits.filter((b) => b === 1).length; } /** @@ -61,4 +60,4 @@ export default class BitSet { toString() { return this.bits.join(''); } -} \ No newline at end of file +} diff --git a/utils/data/configs.js b/utils/data/configs.js index 86454cb..04e5227 100644 --- a/utils/data/configs.js +++ b/utils/data/configs.js @@ -1,14 +1,13 @@ import path from 'path'; import fs from 'fs/promises'; -import {fileURLToPath} from 'url'; +import { fileURLToPath } from 'url'; import Miscellaneous from '../misc.js'; -import {logError, logTags} from '../log/logger.js'; +import { logError, logTags } from '../log/logger.js'; /** * A class to handle project configuration settings. */ export default class ProjectConfigs { - /** * A cached, in-memory object of our configuration. * @@ -123,7 +122,8 @@ export default class ProjectConfigs { const configs = JSON.parse(fileContents); // Update the cached settings if they are not already set. - if (Miscellaneous.isObjectEmpty(this.#cachedSettings)) this.#cachedSettings = configs; + if (Miscellaneous.isObjectEmpty(this.#cachedSettings)) + this.#cachedSettings = configs; return configs; } catch (error) { @@ -145,18 +145,29 @@ export default class ProjectConfigs { if (isPasswordUpdate) { const currentPass = formData['ghosler.auth.pass']; if (Miscellaneous.hash(currentPass) !== configs.ghosler.auth.pass) { - return {level: 'error', message: 'Current password not correct.'}; + return { + level: 'error', + message: 'Current password not correct.', + }; } const newPass = formData['ghosler.auth.new_pass']; const newPassAgain = formData['ghosler.auth.new_pass_confirm']; if (newPass.toString().length < 8) { - return {level: 'error', message: 'New password should at-least be 8 characters long.'}; + return { + level: 'error', + message: + 'New password should at-least be 8 characters long.', + }; } if (newPassAgain !== newPass) { - return {level: 'error', message: 'New Password & Confirmation Password do not match!'}; + return { + level: 'error', + message: + 'New Password & Confirmation Password do not match!', + }; } configs.ghosler.auth.pass = Miscellaneous.hash(newPass); @@ -165,8 +176,13 @@ export default class ProjectConfigs { if (success) { // update password in cache. this.#cachedSettings = configs; - return {level: 'success', message: 'Password updated!'}; - } else return {level: 'error', message: 'Error updating password, check error logs for more info.'}; + return { level: 'success', message: 'Password updated!' }; + } else + return { + level: 'error', + message: + 'Error updating password, check error logs for more info.', + }; } const url = formData['ghosler.url']; @@ -179,29 +195,48 @@ export default class ProjectConfigs { const newsletterCenterTitle = formData['newsletter.center_title']; const newsletterShowFeedback = formData['newsletter.show_feedback']; const newsletterShowComments = formData['newsletter.show_comments']; - const newsletterShowLatestPosts = formData['newsletter.show_latest_posts']; - const newsletterShowSubscription = formData['newsletter.show_subscription']; - const newsletterShowFeaturedImage = formData['newsletter.show_featured_image']; + const newsletterShowLatestPosts = + formData['newsletter.show_latest_posts']; + const newsletterShowSubscription = + formData['newsletter.show_subscription']; + const newsletterShowFeaturedImage = + formData['newsletter.show_featured_image']; const newsletterFooterContent = formData['newsletter.footer_content']; - const newsletterCustomSubject = formData['newsletter.custom_subject_pattern']; - const newsletterPoweredByGhost = formData['newsletter.show_powered_by_ghost']; - const newsletterPoweredByGhosler = formData['newsletter.show_powered_by_ghosler']; + const newsletterCustomSubject = + formData['newsletter.custom_subject_pattern']; + const newsletterPoweredByGhost = + formData['newsletter.show_powered_by_ghost']; + const newsletterPoweredByGhosler = + formData['newsletter.show_powered_by_ghosler']; const customTemplateEnabled = formData['custom_template.enabled']; const email = formData['email']; if (!Array.isArray(email) || email.length === 0) { - return {level: 'error', message: 'Add at-least one email configuration.'}; + return { + level: 'error', + message: 'Add at-least one email configuration.', + }; } configs.ghosler.auth.user = user; if (configs.ghosler.url !== url) configs.ghosler.url = url; - if (ghostUrl === '' || ghostAdminKey === '' || ghostAdminSecret === '') { - return {level: 'error', message: 'Ghost URL, Admin API Key or Secret is missing.'}; + if ( + ghostUrl === '' || + ghostAdminKey === '' || + ghostAdminSecret === '' + ) { + return { + level: 'error', + message: 'Ghost URL, Admin API Key or Secret is missing.', + }; } if (ghostAdminSecret.toString().length < 8) { - return {level: 'error', message: 'Secret should at-least be 8 characters long.'}; + return { + level: 'error', + message: 'Secret should at-least be 8 characters long.', + }; } // ghost @@ -211,41 +246,65 @@ export default class ProjectConfigs { // newsletter configs.newsletter.track_links = newsletterTrackLinks === 'on' ?? true; - configs.newsletter.center_title = newsletterCenterTitle === 'on' ?? false; - configs.newsletter.show_feedback = newsletterShowFeedback === 'on' ?? true; - configs.newsletter.show_comments = newsletterShowComments === 'on' ?? true; - configs.newsletter.show_latest_posts = newsletterShowLatestPosts === 'on' ?? false; - configs.newsletter.show_subscription = newsletterShowSubscription === 'on' ?? false; - configs.newsletter.show_featured_image = newsletterShowFeaturedImage === 'on' ?? true; - configs.newsletter.show_powered_by_ghost = newsletterPoweredByGhost === 'on' ?? true; - configs.newsletter.show_powered_by_ghosler = newsletterPoweredByGhosler === 'on' ?? true; + configs.newsletter.center_title = + newsletterCenterTitle === 'on' ?? false; + configs.newsletter.show_feedback = + newsletterShowFeedback === 'on' ?? true; + configs.newsletter.show_comments = + newsletterShowComments === 'on' ?? true; + configs.newsletter.show_latest_posts = + newsletterShowLatestPosts === 'on' ?? false; + configs.newsletter.show_subscription = + newsletterShowSubscription === 'on' ?? false; + configs.newsletter.show_featured_image = + newsletterShowFeaturedImage === 'on' ?? true; + configs.newsletter.show_powered_by_ghost = + newsletterPoweredByGhost === 'on' ?? true; + configs.newsletter.show_powered_by_ghosler = + newsletterPoweredByGhosler === 'on' ?? true; configs.newsletter.footer_content = newsletterFooterContent; configs.newsletter.custom_subject_pattern = newsletterCustomSubject; // may not exist on an update, so create one anyway. if (!configs.custom_template) configs.custom_template = {}; - configs.custom_template.enabled = customTemplateEnabled === 'on' ?? false; + configs.custom_template.enabled = + customTemplateEnabled === 'on' ?? false; // mail configurations - configs.mail = [...email.map(({batch_size, delay_per_batch, auth_user, auth_pass, ...rest}) => { - return { - ...rest, - batch_size: parseInt(batch_size), - delay_per_batch: parseInt(delay_per_batch), - auth: { - user: auth_user, - pass: auth_pass - } - }; - })]; + configs.mail = [ + ...email.map( + ({ + batch_size, + delay_per_batch, + auth_user, + auth_pass, + ...rest + }) => { + return { + ...rest, + batch_size: parseInt(batch_size), + delay_per_batch: parseInt(delay_per_batch), + auth: { + user: auth_user, + pass: auth_pass, + }, + }; + }, + ), + ]; const success = await this.#write(configs); if (success) { // update the config. cache. this.#cachedSettings = configs; - return {level: 'success', message: 'Settings updated!'}; - } else return {level: 'error', message: 'Error updating settings, check error logs for more info.'}; + return { level: 'success', message: 'Settings updated!' }; + } else + return { + level: 'error', + message: + 'Error updating settings, check error logs for more info.', + }; } /** @@ -257,10 +316,14 @@ export default class ProjectConfigs { static async updateCustomTemplate(templateFile) { try { await templateFile.mv('./configuration/custom-template.ejs'); - return {level: 'success', message: 'Template file uploaded!'}; + return { level: 'success', message: 'Template file uploaded!' }; } catch (error) { logError(logTags.Configs, error); - return {level: 'error', message: 'Error updating settings, check error logs for more info.'}; + return { + level: 'error', + message: + 'Error updating settings, check error logs for more info.', + }; } } @@ -290,8 +353,14 @@ export default class ProjectConfigs { static async #getConfigFilePath() { const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const debugConfigPath = path.resolve(__dirname, '../../configuration/config.local.json'); - const prodConfigPath = path.resolve(__dirname, '../../configuration/config.production.json'); + const debugConfigPath = path.resolve( + __dirname, + '../../configuration/config.local.json', + ); + const prodConfigPath = path.resolve( + __dirname, + '../../configuration/config.production.json', + ); try { await fs.access(debugConfigPath); @@ -307,8 +376,10 @@ export default class ProjectConfigs { static #fetchGhoslerVersion() { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageFilePath = path.resolve(__dirname, '../../package.json'); - fs.readFile(packageFilePath, 'utf8').then(fileContent => { - this.ghoslerVersion = JSON.parse(fileContent).version; - }).catch((_) => this.ghoslerVersion = ''); + fs.readFile(packageFilePath, 'utf8') + .then((fileContent) => { + this.ghoslerVersion = JSON.parse(fileContent).version; + }) + .catch((_) => (this.ghoslerVersion = '')); } -} \ No newline at end of file +} diff --git a/utils/data/files.js b/utils/data/files.js index 382f038..bf23e2e 100644 --- a/utils/data/files.js +++ b/utils/data/files.js @@ -1,13 +1,12 @@ import path from 'path'; import fs from 'fs/promises'; import BitSet from '../bitset.js'; -import {logDebug, logError, logTags} from '../log/logger.js'; +import { logDebug, logError, logTags } from '../log/logger.js'; /** * This class manages the analytics for each post sent via email as a newsletter. */ export default class Files { - /** * Get the files' directory. */ @@ -67,7 +66,6 @@ export default class Files { static async get(postId) { const isExists = await this.exists(postId); if (!isExists) return undefined; - else { const filePath = path.join(this.#filesPath(), `${postId}.json`); @@ -132,31 +130,39 @@ export default class Files { let totalSent = 0; let totalOpens = 0; - const analytics = await Promise.all(files.map(async (file) => { - const filePath = path.join(this.#filesPath(), file); - const data = JSON.parse(await fs.readFile(filePath, 'utf8')); + const analytics = await Promise.all( + files.map(async (file) => { + const filePath = path.join(this.#filesPath(), file); + const data = JSON.parse( + await fs.readFile(filePath, 'utf8'), + ); - const numbered = new BitSet(data.stats.emailsOpened ?? '').popCount(); + const numbered = new BitSet( + data.stats.emailsOpened ?? '', + ).popCount(); - totalPosts += 1; - totalOpens += numbered; - totalSent += data.stats.emailsSent ? data.stats.emailsSent : 0; + totalPosts += 1; + totalOpens += numbered; + totalSent += data.stats.emailsSent + ? data.stats.emailsSent + : 0; - data.stats.emailsOpened = numbered; + data.stats.emailsOpened = numbered; - return data; - })); + return data; + }), + ); const overview = { posts: totalPosts, sent: totalSent, - opens: totalOpens + opens: totalOpens, }; - return {overview, analytics}; + return { overview, analytics }; } catch (error) { logError(logTags.Files, error); - return {overview: {posts: 0, sent: 0, opens: 0}, analytics: []}; + return { overview: { posts: 0, sent: 0, opens: 0 }, analytics: [] }; } } @@ -190,7 +196,7 @@ export default class Files { let logContent = await fs.readFile(filePath, 'utf8'); logContent = this.#reverseLogEntries(logContent); - return {content: logContent, level: type}; + return { content: logContent, level: type }; } catch (error) { if (error.toString().includes('no such file or directory')) { // Check and create the directory if it doesn't exist @@ -203,7 +209,7 @@ export default class Files { } else logError(logTags.Files, error); // empty anyway! - return {content: '', level: type}; + return { content: '', level: type }; } } @@ -238,7 +244,7 @@ export default class Files { */ static async makeFilesDir(directory = this.#filesPath()) { // Create if it doesn't exist. - await fs.mkdir(directory, {recursive: true}); + await fs.mkdir(directory, { recursive: true }); } /** @@ -253,7 +259,7 @@ export default class Files { const entries = []; let currentEntry = []; - logContent.split('\n').forEach(line => { + logContent.split('\n').forEach((line) => { const trimmedLine = line.trim(); if (entryPattern.test(trimmedLine)) { if (currentEntry.length) { @@ -274,4 +280,4 @@ export default class Files { // Reverse and join the entries, no additional newlines are needed return entries.reverse().join('\n'); } -} \ No newline at end of file +} diff --git a/utils/data/ops/emails_queue.js b/utils/data/ops/emails_queue.js index 93e3ee5..4a8d293 100644 --- a/utils/data/ops/emails_queue.js +++ b/utils/data/ops/emails_queue.js @@ -1,13 +1,12 @@ import Files from '../files.js'; import BitSet from '../../bitset.js'; import Miscellaneous from '../../misc.js'; -import {logDebug, logError, logTags} from '../../log/logger.js'; +import { logDebug, logError, logTags } from '../../log/logger.js'; /** * A queue class for batching and processing updates to tracking statistics. */ export default class EmailsQueue { - /** * Creates a new Queue instance. * @@ -67,7 +66,7 @@ export default class EmailsQueue { let requiresFileUpdate = false; const bitSet = new BitSet(post.stats.emailsOpened); - memberIndexes.forEach(index => { + memberIndexes.forEach((index) => { if (bitSet.get(index) === 0) { bitSet.set(index, 1); requiresFileUpdate = true; @@ -80,10 +79,13 @@ export default class EmailsQueue { const saved = await Files.create(post, true); if (saved) { - logDebug(logTags.Stats, `Batched tracking updated for post: ${post.title}.`); + logDebug( + logTags.Stats, + `Batched tracking updated for post: ${post.title}.`, + ); } } catch (error) { logError(logTags.Stats, error); } } -} \ No newline at end of file +} diff --git a/utils/data/ops/links_queue.js b/utils/data/ops/links_queue.js index 0797168..29fcee7 100644 --- a/utils/data/ops/links_queue.js +++ b/utils/data/ops/links_queue.js @@ -1,11 +1,10 @@ import Files from '../files.js'; -import {logDebug, logError, logTags} from '../../log/logger.js'; +import { logDebug, logError, logTags } from '../../log/logger.js'; /** * A queue class for batching and processing updates to links click tracking statistics. */ export default class LinksQueue { - /** * Creates a new Queue instance. * @@ -69,17 +68,21 @@ export default class LinksQueue { const post = await Files.get(postId); if (!post) return; - post.stats.postContentTrackedLinks.forEach(linkObject => { + post.stats.postContentTrackedLinks.forEach((linkObject) => { const linkUrl = Object.keys(linkObject)[0]; - if (urlStats.has(linkUrl)) linkObject[linkUrl] += urlStats.get(linkUrl); + if (urlStats.has(linkUrl)) + linkObject[linkUrl] += urlStats.get(linkUrl); }); const saved = await Files.create(post, true); if (saved) { - logDebug(logTags.Stats, `Batched link click tracking updated for post: ${post.title}.`); + logDebug( + logTags.Stats, + `Batched link click tracking updated for post: ${post.title}.`, + ); } } catch (error) { logError(logTags.Stats, error); } } -} \ No newline at end of file +} diff --git a/utils/log/logger.js b/utils/log/logger.js index 120143c..b8b5549 100644 --- a/utils/log/logger.js +++ b/utils/log/logger.js @@ -56,4 +56,4 @@ function log(tag, message, level) { if (level === 'debug') winstonLogger.debug(messageWithTag); else if (level === 'error') winstonLogger.error(messageWithTag); -} \ No newline at end of file +} diff --git a/utils/log/options.js b/utils/log/options.js index a7437e1..82a326a 100644 --- a/utils/log/options.js +++ b/utils/log/options.js @@ -45,9 +45,13 @@ const filterOnly = (level) => { * @param {string} level - The log level for which the file transport should be configured. * @returns {winston.FileTransportInstance} - A Winston file transport instance configured for the specified level. */ -const transport = (level) => new winston.transports.File({ - level: level, dirname: '.logs', filename: `${level}.log`, format: filterOnly(level) -}); +const transport = (level) => + new winston.transports.File({ + level: level, + dirname: '.logs', + filename: `${level}.log`, + format: filterOnly(level), + }); /** * `options` contains the configurations for the logger. @@ -57,7 +61,7 @@ const transport = (level) => new winston.transports.File({ */ const options = { format: logFormat(), - transports: [transport('debug'), transport('error')] + transports: [transport('debug'), transport('error')], }; -export default options; \ No newline at end of file +export default options; diff --git a/utils/mail/mailer.js b/utils/mail/mailer.js index ac68100..f614222 100644 --- a/utils/mail/mailer.js +++ b/utils/mail/mailer.js @@ -1,13 +1,12 @@ import Miscellaneous from '../misc.js'; import * as nodemailer from 'nodemailer'; import ProjectConfigs from '../data/configs.js'; -import {logDebug, logError, logTags, logToConsole} from '../log/logger.js'; +import { logDebug, logError, logTags, logToConsole } from '../log/logger.js'; /** * Class responsible for sending newsletters to subscribers. */ export default class NewsletterMailer { - /** * Creates an instance of NewsletterMailer. * @@ -18,7 +17,14 @@ export default class NewsletterMailer { * @param {string|undefined} partialContent - Partial HTML content of the email for non-paying users. * @param {string} unsubscribeLink - An unsubscribe link for the subscribers. */ - constructor(post, subscribers, newsletterName, fullContent, partialContent, unsubscribeLink) { + constructor( + post, + subscribers, + newsletterName, + fullContent, + partialContent, + unsubscribeLink, + ) { this.post = post; this.subscribers = subscribers; this.newsletterName = newsletterName; @@ -39,28 +45,49 @@ export default class NewsletterMailer { let totalEmailsSent = 0; const mailConfigs = await ProjectConfigs.mail(); - let tierIds = this.post.isPaid ? [...this.post.tiers.map(tier => tier.id)] : []; + let tierIds = this.post.isPaid + ? [...this.post.tiers.map((tier) => tier.id)] + : []; if (mailConfigs.length > 1 && this.subscribers.length > 1) { - logDebug(logTags.Newsletter, 'More than one subscriber & email configs found, splitting the subscribers.'); + logDebug( + logTags.Newsletter, + 'More than one subscriber & email configs found, splitting the subscribers.', + ); - const chunkSize = Math.ceil(this.subscribers.length / mailConfigs.length); + const chunkSize = Math.ceil( + this.subscribers.length / mailConfigs.length, + ); for (let i = 0; i < mailConfigs.length; i++) { - // settings const mailConfig = mailConfigs[i]; const emailsPerBatch = mailConfig.batch_size ?? 10; const delayPerBatch = mailConfig.delay_per_batch ?? 1250; - const chunkedSubscribers = this.subscribers.slice(i * chunkSize, (i + 1) * chunkSize); + const chunkedSubscribers = this.subscribers.slice( + i * chunkSize, + (i + 1) * chunkSize, + ); // create required batches and send. - const batches = this.#createBatches(chunkedSubscribers, emailsPerBatch); + const batches = this.#createBatches( + chunkedSubscribers, + emailsPerBatch, + ); // we need increment this stat as we are inside a loop. - totalEmailsSent += await this.#processBatches(mailConfig, batches, chunkSize, tierIds, delayPerBatch); + totalEmailsSent += await this.#processBatches( + mailConfig, + batches, + chunkSize, + tierIds, + delayPerBatch, + ); } } else { - logDebug(logTags.Newsletter, 'Single user or email config found, sending email(s).'); + logDebug( + logTags.Newsletter, + 'Single user or email config found, sending email(s).', + ); // settings const mailConfig = mailConfigs[0]; @@ -68,8 +95,17 @@ export default class NewsletterMailer { const delayPerBatch = mailConfig.delay_per_batch ?? 1250; // create required batches and send. - const batches = this.#createBatches(this.subscribers, emailsPerBatch); - totalEmailsSent = await this.#processBatches(mailConfig, batches, emailsPerBatch, tierIds, delayPerBatch); + const batches = this.#createBatches( + this.subscribers, + emailsPerBatch, + ); + totalEmailsSent = await this.#processBatches( + mailConfig, + batches, + emailsPerBatch, + tierIds, + delayPerBatch, + ); } // Update post status and save it. @@ -92,7 +128,13 @@ export default class NewsletterMailer { * * @returns {Promise} - Promise resolving to true if email was sent successfully, false otherwise. */ - async #sendEmailToSubscriber(transporter, mailConfig, subscriber, index, html) { + async #sendEmailToSubscriber( + transporter, + mailConfig, + subscriber, + index, + html, + ) { const correctHTML = this.#correctHTML(html, subscriber, index); const customSubject = await this.#makeEmailSubject(subscriber); @@ -106,12 +148,15 @@ export default class NewsletterMailer { list: { unsubscribe: { comment: 'Unsubscribe', - url: this.unsubscribeLink.replace('{MEMBER_UUID}', subscriber.uuid), + url: this.unsubscribeLink.replace( + '{MEMBER_UUID}', + subscriber.uuid, + ), }, }, headers: { - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' - } + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, }); return info.response.includes('250'); @@ -137,14 +182,17 @@ export default class NewsletterMailer { .replace('19 September 2013', subscriber.created) // default value due to preview .replace('jamie@example.com', subscriber.email) // default value due to preview .replace('free subscriber', `${subscriber.status} subscriber`) // default value due to preview - .replace('{TRACKING_PIXEL_LINK}', Miscellaneous.encode(`${this.post.id}_${index}`)); + .replace( + '{TRACKING_PIXEL_LINK}', + Miscellaneous.encode(`${this.post.id}_${index}`), + ); if (subscriber.name === '') { // we use wrong class tag to keep the element visible, // use the right one to hide it as it is defined in the styles. source = source.replace( - 'class=\"wrong-user-subscription-name-field\"', - 'class=\"user-subscription-name-field\"' + 'class="wrong-user-subscription-name-field"', + 'class="user-subscription-name-field"', ); } @@ -160,7 +208,8 @@ export default class NewsletterMailer { async #makeEmailSubject(subscriber) { // already cached => fast path. const newsletterConfig = await ProjectConfigs.newsletter(); - let customSubject = newsletterConfig.custom_subject_pattern || this.post.title; + let customSubject = + newsletterConfig.custom_subject_pattern || this.post.title; customSubject = customSubject .replace('{{post_title}}', this.post.title) @@ -168,13 +217,28 @@ export default class NewsletterMailer { // a post may not have a primary tag. if (customSubject.includes('{{primary_tag}}')) { - if (this.post.primaryTag) customSubject = customSubject.replace('{{primary_tag}}', this.post.primaryTag); - else customSubject = customSubject.replace(/( • #| • )?{{primary_tag}}/, ''); + if (this.post.primaryTag) + customSubject = customSubject.replace( + '{{primary_tag}}', + this.post.primaryTag, + ); + else + customSubject = customSubject.replace( + /( • #| • )?{{primary_tag}}/, + '', + ); } if (customSubject.includes('{{newsletter_name}}')) { - const nlsName = this.newsletterName ?? subscriber.newsletters.filter(nls => nls.status === 'active')[0].name; - customSubject = customSubject.replace('{{newsletter_name}}', nlsName); + const nlsName = + this.newsletterName ?? + subscriber.newsletters.filter( + (nls) => nls.status === 'active', + )[0].name; + customSubject = customSubject.replace( + '{{newsletter_name}}', + nlsName, + ); } return customSubject; @@ -192,7 +256,7 @@ export default class NewsletterMailer { secure: true, host: mailConfig.host, port: mailConfig.port, - auth: {user: mailConfig.auth.user, pass: mailConfig.auth.pass} + auth: { user: mailConfig.auth.user, pass: mailConfig.auth.pass }, }); } @@ -223,7 +287,13 @@ export default class NewsletterMailer { * * @returns {Promise} Total emails sent. */ - async #processBatches(mailConfig, batches, chunkSize, tierIds, delayBetweenBatches) { + async #processBatches( + mailConfig, + batches, + chunkSize, + tierIds, + delayBetweenBatches, + ) { let emailsSent = 0; const totalBatchLength = batches.length; const transporter = await this.#transporter(mailConfig); @@ -235,25 +305,37 @@ export default class NewsletterMailer { const promises = [ ...batch.map((subscriber, index) => { const globalIndex = startIndex + index; - const contentToSend = this.post.isPaid ? - subscriber.isPaying(tierIds) ? - this.fullContent : - this.partialContent ?? this.fullContent + const contentToSend = this.post.isPaid + ? subscriber.isPaying(tierIds) + ? this.fullContent + : this.partialContent ?? this.fullContent : this.fullContent; - return this.#sendEmailToSubscriber(transporter, mailConfig, subscriber, globalIndex, contentToSend); - }) + return this.#sendEmailToSubscriber( + transporter, + mailConfig, + subscriber, + globalIndex, + contentToSend, + ); + }), ]; const batchResults = await Promise.allSettled(promises); - emailsSent += batchResults.filter(result => result.value === true).length; + emailsSent += batchResults.filter( + (result) => result.value === true, + ).length; if (totalBatchLength > 1) { - logToConsole(logTags.Newsletter, `Batch ${batchIndex + 1}/${totalBatchLength} complete.`); + logToConsole( + logTags.Newsletter, + `Batch ${batchIndex + 1}/${totalBatchLength} complete.`, + ); } - if (batchIndex < batches.length - 1) await Miscellaneous.sleep(delayBetweenBatches); + if (batchIndex < batches.length - 1) + await Miscellaneous.sleep(delayBetweenBatches); } return emailsSent; } -} \ No newline at end of file +} diff --git a/utils/misc.js b/utils/misc.js index e774217..483db7b 100644 --- a/utils/misc.js +++ b/utils/misc.js @@ -5,14 +5,13 @@ import Files from './data/files.js'; import cookieSession from 'cookie-session'; import fileUpload from 'express-fileupload'; import ProjectConfigs from './data/configs.js'; -import {extract} from '@extractus/oembed-extractor'; -import {logDebug, logError, logTags} from './log/logger.js'; +import { extract } from '@extractus/oembed-extractor'; +import { logDebug, logError, logTags } from './log/logger.js'; /** * This class provides general utility methods. */ export default class Miscellaneous { - /** * Set up miscellaneous middlewares and configurations for a given express app instance. * @@ -21,17 +20,25 @@ export default class Miscellaneous { */ static async setup(expressApp) { expressApp.set('view engine', 'ejs'); - expressApp.use(express.static("public")); - expressApp.use(express.json({limit: '50mb'})); - expressApp.use(express.urlencoded({extended: true, limit: '50mb'})); - expressApp.use(fileUpload({safeFileNames: true, preserveExtension: true, useTempFiles: false})); + expressApp.use(express.static('public')); + expressApp.use(express.json({ limit: '50mb' })); + expressApp.use(express.urlencoded({ extended: true, limit: '50mb' })); + expressApp.use( + fileUpload({ + safeFileNames: true, + preserveExtension: true, + useTempFiles: false, + }), + ); // login sessions. - expressApp.use(cookieSession({ - name: 'ghosler', - maxAge: 24 * 60 * 60 * 1000, - secret: crypto.randomUUID(), // dynamic secret, always invalidated on a restart. - })); + expressApp.use( + cookieSession({ + name: 'ghosler', + maxAge: 24 * 60 * 60 * 1000, + secret: crypto.randomUUID(), // dynamic secret, always invalidated on a restart. + }), + ); // Safeguard await Files.makeFilesDir(); @@ -64,14 +71,16 @@ export default class Miscellaneous { expressApp.all('*', async (req, res, next) => { const path = req.path; const isUnrestrictedPath = /\/login$|\/preview$|\/track/.test(path); - const isPostPublish = req.method === 'POST' && /\/published$/.test(path); + const isPostPublish = + req.method === 'POST' && /\/published$/.test(path); if (isUnrestrictedPath || isPostPublish) return next(); if (req.session.user) return next(); // redirect to page the user wanted to go to, after auth. - const redirect = path !== '/' ? `?redirect=${encodeURIComponent(path)}` : ''; + const redirect = + path !== '/' ? `?redirect=${encodeURIComponent(path)}` : ''; res.status(401).redirect(`/login${redirect}`); }); } @@ -83,7 +92,8 @@ export default class Miscellaneous { */ static trackingPixel() { return Buffer.from( - 'R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64' + 'R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + 'base64', ); } @@ -95,17 +105,26 @@ export default class Miscellaneous { */ static async authenticated(req) { if (!req.body || !req.body.username || !req.body.password) { - return {level: 'error', message: 'Please enter both Username & Password!'}; + return { + level: 'error', + message: 'Please enter both Username & Password!', + }; } - const {username, password} = req.body; + const { username, password } = req.body; const ghosler = await ProjectConfigs.ghosler(); - if (username === ghosler.auth.user && this.hash(password) === ghosler.auth.pass) { + if ( + username === ghosler.auth.user && + this.hash(password) === ghosler.auth.pass + ) { req.session.user = ghosler.auth.user; - return {level: 'success', message: 'Successfully logged in!'}; + return { level: 'success', message: 'Successfully logged in!' }; } else { - return {level: 'error', message: 'Username or Password does not match!'}; + return { + level: 'error', + message: 'Username or Password does not match!', + }; } } @@ -119,7 +138,20 @@ export default class Miscellaneous { const date = new Date(dateString); const day = String(date.getDate()).padStart(2, '0'); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; const month = months[date.getMonth()]; const year = date.getFullYear(); @@ -158,7 +190,7 @@ export default class Miscellaneous { */ static detectUnsplashImage(imageUrl) { return /images\.unsplash\.com/.test(imageUrl); - }; + } /** * Removes the tracking if it exists and returns a clean url. @@ -219,7 +251,7 @@ export default class Miscellaneous { * @returns {Promise} */ static sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -265,7 +297,10 @@ export default class Miscellaneous { // Secret set on Ghosler but not recd. in the request headers. if (ghostConfigs.secret && !signatureWithDateHeader) { - logError(logTags.Express, 'The \'X-Ghost-Signature\' header not found in the request. Did you setup the Secret Key correctly?'); + logError( + logTags.Express, + "The 'X-Ghost-Signature' header not found in the request. Did you setup the Secret Key correctly?", + ); return false; } @@ -275,19 +310,26 @@ export default class Miscellaneous { const signature = signatureAndTimeStamp[0].replace('sha256=', ''); const timeStamp = parseInt(signatureAndTimeStamp[1].replace('t=', '')); if (!signature || isNaN(timeStamp)) { - logError(logTags.Express, 'Either the signature or the timestamp in the \'X-Ghost-Signature\' header is not valid or doesn\'t exist.'); + logError( + logTags.Express, + "Either the signature or the timestamp in the 'X-Ghost-Signature' header is not valid or doesn't exist.", + ); return false; } const maxTimeDiff = 5 * 60 * 1000; // 5 minutes if (Math.abs(Date.now() - timeStamp) > maxTimeDiff) { - logError(logTags.Express, 'The timestamp in the \'X-Ghost-Signature\' header exceeds 5 minutes.'); + logError( + logTags.Express, + "The timestamp in the 'X-Ghost-Signature' header exceeds 5 minutes.", + ); return false; } const expectedSignature = crypto .createHmac('sha256', ghostConfigs.secret) - .update(payload).digest('hex'); + .update(payload) + .digest('hex'); return signature === expectedSignature; } @@ -304,7 +346,10 @@ export default class Miscellaneous { const [id, secret] = key.split(':'); return jwt.sign({}, Buffer.from(secret, 'hex'), { - keyid: id, algorithm: 'HS256', expiresIn: '5m', audience + keyid: id, + algorithm: 'HS256', + expiresIn: '5m', + audience, }); } } diff --git a/utils/models/post.js b/utils/models/post.js index 4aafcff..7030b9d 100644 --- a/utils/models/post.js +++ b/utils/models/post.js @@ -36,7 +36,7 @@ export default class Post { primaryAuthor = '', visibility = '', tiers = [], - stats = new Stats() + stats = new Stats(), ) { this.id = id; this.url = url; @@ -78,13 +78,16 @@ export default class Post { post.title, post.html, post.primary_tag?.name ?? '', - post.custom_excerpt ?? post.excerpt ?? post.plaintext?.substring(0, 75) ?? (post.title + '...'), + post.custom_excerpt ?? + post.excerpt ?? + post.plaintext?.substring(0, 75) ?? + post.title + '...', post.feature_image, post.feature_image_caption, post.primary_author.name, post.visibility, post.tiers, - new Stats() + new Stats(), ); } @@ -109,7 +112,7 @@ export default class Post { static containsIgnoreTag(payload) { // it is always an array. const postTags = payload.post.current.tags; - return postTags.some(tag => tag.slug === 'ghosler_ignore'); + return postTags.some((tag) => tag.slug === 'ghosler_ignore'); } /** @@ -161,8 +164,7 @@ export default class Post { date: this.date, title: this.title, author: this.primaryAuthor, - stats: this.stats + stats: this.stats, }; } } - diff --git a/utils/models/stats.js b/utils/models/stats.js index 31fa482..3afc807 100644 --- a/utils/models/stats.js +++ b/utils/models/stats.js @@ -2,7 +2,6 @@ * Represents statistics related to a Post. */ export default class Stats { - /** * Creates an instance of Stats. * @@ -19,7 +18,7 @@ export default class Stats { emailsOpened = '', newsletterName = '', newsletterStatus = 'na', - postContentTrackedLinks = [] + postContentTrackedLinks = [], ) { this.members = members; this.emailsSent = emailsSent; @@ -28,4 +27,4 @@ export default class Stats { this.newsletterStatus = newsletterStatus; this.postContentTrackedLinks = postContentTrackedLinks; } -} \ No newline at end of file +} diff --git a/utils/models/subscriber.js b/utils/models/subscriber.js index 249792b..cc8659a 100644 --- a/utils/models/subscriber.js +++ b/utils/models/subscriber.js @@ -5,7 +5,15 @@ import Miscellaneous from '../misc.js'; * A class that represents a user on the site who has enabled receiving the newsletters via email. */ export default class Subscriber { - constructor(uuid, name, email, status, created, newsletters = [], subscriptions = []) { + constructor( + uuid, + name, + email, + status, + created, + newsletters = [], + subscriptions = [], + ) { this.uuid = uuid; this.name = name; this.email = email; @@ -31,7 +39,7 @@ export default class Subscriber { jsonObject.status, jsonObject.created_at, jsonObject.newsletters, - jsonObject.subscriptions + jsonObject.subscriptions, ); } @@ -42,7 +50,9 @@ export default class Subscriber { * @returns {boolean} True if this is a paying member & has an active subscription. */ isPaying(tierIds) { - const hasTier = this.subscriptions.some(subscription => tierIds.includes(subscription.tier.id)); + const hasTier = this.subscriptions.some((subscription) => + tierIds.includes(subscription.tier.id), + ); // possible values are 'active', 'expired', 'canceled'. // also see why we use the first subscription object: @@ -60,6 +70,9 @@ export default class Subscriber { // probably no/one newsletter exists. if (newsletterId === null) return true; else if (newsletterId === Ghost.genericNewsletterItem.id) return true; - else return this.newsletters.some(newsletter => newsletter.id === newsletterId); + else + return this.newsletters.some( + (newsletter) => newsletter.id === newsletterId, + ); } -} \ No newline at end of file +} diff --git a/utils/newsletter.js b/utils/newsletter.js index 90b9c25..92372a3 100644 --- a/utils/newsletter.js +++ b/utils/newsletter.js @@ -8,10 +8,9 @@ import Ghost from './api/ghost.js'; import Files from './data/files.js'; import ProjectConfigs from './data/configs.js'; import NewsletterMailer from './mail/mailer.js'; -import {logDebug, logError, logTags} from './log/logger.js'; +import { logDebug, logError, logTags } from './log/logger.js'; export default class Newsletter { - /** * Send email to members of the site. * @@ -23,32 +22,42 @@ export default class Newsletter { const subscribers = await ghost.members(newsletterInfo?.id); if (subscribers.length === 0) { - logDebug(logTags.Newsletter, 'Site has no registered or subscribed users, cancelling sending emails!'); + logDebug( + logTags.Newsletter, + 'Site has no registered or subscribed users, cancelling sending emails!', + ); return; } else { - logDebug(logTags.Newsletter, `${subscribers.length} users have enabled receiving newsletters.`); + logDebug( + logTags.Newsletter, + `${subscribers.length} users have enabled receiving newsletters.`, + ); } const fullRenderData = await this.#makeRenderingData(post, ghost); - const {trackedLinks, modifiedHtml: fullTemplate} = await this.renderTemplate(fullRenderData); + const { trackedLinks, modifiedHtml: fullTemplate } = + await this.renderTemplate(fullRenderData); let payWalledTemplate; if (post.isPaid) { const partialRenderData = this.#removePaidContent(fullRenderData); - payWalledTemplate = (await this.renderTemplate(partialRenderData)).modifiedHtml; + payWalledTemplate = (await this.renderTemplate(partialRenderData)) + .modifiedHtml; } if (trackedLinks.length > 0) { - trackedLinks.forEach(link => { - post.stats.postContentTrackedLinks.push({[link]: 0}); + trackedLinks.forEach((link) => { + post.stats.postContentTrackedLinks.push({ [link]: 0 }); }); } await new NewsletterMailer( - post, subscribers, + post, + subscribers, newsletterInfo?.name, - fullTemplate, payWalledTemplate, - fullRenderData.newsletter.unsubscribeLink + fullTemplate, + payWalledTemplate, + fullRenderData.newsletter.unsubscribeLink, ).send(); } @@ -85,14 +94,14 @@ export default class Newsletter { featuredImage: post.featureImage, featuredImageCaption: post.featureImageCaption, latestPosts: [], - showPaywall: false + showPaywall: false, }, newsletter: { subscription: `${site.url}#/portal/account`, trackingPixel: `${ghosler.url}/track/pixel.png?uuid={TRACKING_PIXEL_LINK}`, unsubscribeLink: `${site.url}unsubscribe?uuid={MEMBER_UUID}`, feedbackLikeLink: `${site.url}#/feedback/${post.id}/1/?uuid={MEMBER_UUID}`, - feedbackDislikeLink: `${site.url}#/feedback/${post.id}/0/?uuid={MEMBER_UUID}` + feedbackDislikeLink: `${site.url}#/feedback/${post.id}/0/?uuid={MEMBER_UUID}`, }, }; @@ -102,8 +111,10 @@ export default class Newsletter { postData.show_subscription = customisations.show_subscription; postData.show_featured_image = customisations.show_featured_image; postData.show_powered_by_ghost = customisations.show_powered_by_ghost; - postData.show_powered_by_ghosler = customisations.show_powered_by_ghosler; - postData.show_comments = customisations.show_comments && hasCommentsEnabled; + postData.show_powered_by_ghosler = + customisations.show_powered_by_ghosler; + postData.show_comments = + customisations.show_comments && hasCommentsEnabled; if (customisations.footer_content !== '') { postData.footer_content = customisations.footer_content; @@ -111,14 +122,15 @@ export default class Newsletter { if (customisations.show_latest_posts) { const latestPosts = await ghost.latest(post.id); - postData.post.latestPosts = latestPosts.map(post => ({ + postData.post.latestPosts = latestPosts.map((post) => ({ title: post.title, url: post.url, featuredImage: post.feature_image, preview: ( post.custom_excerpt ?? - post.excerpt ?? 'Click to read & discover more in the full article.' - ).replace(/\n/g, '. ') + post.excerpt ?? + 'Click to read & discover more in the full article.' + ).replace(/\n/g, '. '), })); } @@ -136,9 +148,10 @@ export default class Newsletter { static async renderTemplate(renderingData) { let templatePath; const injectUrlTracking = renderingData.trackLinks; - const isCustomTemplateEnabled = (await ProjectConfigs.customTemplate()).enabled; + const isCustomTemplateEnabled = (await ProjectConfigs.customTemplate()) + .enabled; - if (isCustomTemplateEnabled && await Files.customTemplateExists()) { + if (isCustomTemplateEnabled && (await Files.customTemplateExists())) { templatePath = Files.customTemplatePath(); } else templatePath = path.join(process.cwd(), '/views/newsletter.ejs'); @@ -146,7 +159,7 @@ export default class Newsletter { const template = await ejs.renderFile(templatePath, renderingData); const injectedHtml = injectUrlTracking ? await this.#injectUrlTracking(renderingData, template) - : {trackedLinks: new Set(), modifiedHtml: template}; + : { trackedLinks: new Set(), modifiedHtml: template }; // add widgets, inline css and minify the html. return await Widgets.replace(renderingData, injectedHtml); @@ -163,7 +176,10 @@ export default class Newsletter { const segmentIndex = postContent.indexOf(''); if (segmentIndex !== -1) { renderedPostData.post.showPaywall = true; - renderedPostData.post.content = postContent.substring(0, segmentIndex); + renderedPostData.post.content = postContent.substring( + 0, + segmentIndex, + ); } return renderedPostData; @@ -178,9 +194,11 @@ export default class Newsletter { const domainsToExclude = [ new URL('https://static.ghost.org').host, - ghosler.url && ( - ghosler.url.startsWith('http://') || ghosler.url.startsWith('https://') - ) ? new URL(ghosler.url).host : null, + ghosler.url && + (ghosler.url.startsWith('http://') || + ghosler.url.startsWith('https://')) + ? new URL(ghosler.url).host + : null, ].filter(Boolean); let urlsToExclude = []; @@ -192,7 +210,9 @@ export default class Newsletter { // a post may also not have a caption. if (renderingData.post.featuredImageCaption) { - const $caption = cheerio.load(renderingData.post.featuredImageCaption); + const $caption = cheerio.load( + renderingData.post.featuredImageCaption, + ); $caption('a').each((_, elem) => { urlsToExclude.push(he.decode($caption(elem).attr('href'))); }); @@ -208,26 +228,37 @@ export default class Newsletter { * This way we can be sure to track links to other articles as well, example: Keep Reading section. */ [ - renderingData.site.url, renderingData.site.logo, - renderingData.post.url, renderingData.post.comments, + renderingData.site.url, + renderingData.site.logo, + renderingData.post.url, + renderingData.post.comments, renderingData.newsletter.subscription, renderingData.newsletter.unsubscribeLink, renderingData.newsletter.feedbackLikeLink, renderingData.newsletter.feedbackDislikeLink, - ...renderingData.post.latestPosts.map(post => post.featuredImage) - ].forEach(linkToTrack => linkToTrack && urlsToExclude.push(he.decode(linkToTrack))); + ...renderingData.post.latestPosts.map((post) => post.featuredImage), + ].forEach( + (linkToTrack) => + linkToTrack && urlsToExclude.push(he.decode(linkToTrack)), + ); const trackedLinks = new Set(); const $ = cheerio.load(renderedPostData); // these elements are added as a part of a main element. // example: the bookmark can include a favicon and an img tag. - const elementsToExclude = ['.kg-bookmark-icon', '.kg-bookmark-thumbnail', '.kg-file-card-container']; + const elementsToExclude = [ + '.kg-bookmark-icon', + '.kg-bookmark-thumbnail', + '.kg-file-card-container', + ]; // we don't need to worry about the urls in // audio & video cards as we only target the anchor and image tags. $('a[href], img[src], iframe[src]').each((_, element) => { - const isExcluded = elementsToExclude.some(cls => $(element).closest(cls).length > 0); + const isExcluded = elementsToExclude.some( + (cls) => $(element).closest(cls).length > 0, + ); if (isExcluded) return; const tag = $(element).is('a') ? 'href' : 'src'; @@ -242,17 +273,24 @@ export default class Newsletter { try { urlHost = new URL(elementUrl).host; } catch (error) { - logError(logTags.Newsletter, Error(`Invalid URL found: ${elementUrl}, ${error.stack}.`)); + logError( + logTags.Newsletter, + Error(`Invalid URL found: ${elementUrl}, ${error.stack}.`), + ); } - if (urlHost && !domainsToExclude.includes(urlHost) && !urlsToExclude.includes(elementUrl)) { + if ( + urlHost && + !domainsToExclude.includes(urlHost) && + !urlsToExclude.includes(elementUrl) + ) { trackedLinks.add(elementUrl); $(element).attr(tag, `${pingUrl}${elementUrl}`); } }); // Convert the set to an array and return along with modified HTML - return {trackedLinks: trackedLinks, modifiedHtml: $.html()}; + return { trackedLinks: trackedLinks, modifiedHtml: $.html() }; } // Sample data for preview. @@ -264,7 +302,7 @@ export default class Newsletter { url: 'https://bulletin.ghost.io/', logo: 'https://bulletin.ghost.io/content/images/size/w256h256/2021/06/ghost-orb-black-transparent-10--1-.png', title: 'Bulletin', - description: 'Thoughts, stories and ideas.' + description: 'Thoughts, stories and ideas.', }, post: { id: '60d14faa9e72bc002f16c727', @@ -272,20 +310,26 @@ export default class Newsletter { date: '22 June 2021', title: 'Welcome', author: 'Ghost', - preview: 'We\'ve crammed the most important information to help you get started with Ghost into this one post. It\'s your cheat-sheet to get started, and your shortcut to advanced features.', - content: '

Hey there, welcome to your new home on the web!


Ghost

Ghost is an independent, open source app, which means you can customize absolutely everything. Inside the admin area, you\'ll find straightforward controls for changing themes, colors, navigation, logos and settings — so you can set your site up just how you like it. No technical knowledge required.


Ghosler

Ghosler is an open source project designed for those starting with Ghost or managing a small to moderate user base. It provides extensive control over newsletter settings and customization, enhancing your outreach with features like URL Click Tracking, Newsletter Feedback, Email Deliverability, and Email Open Rate Analytics. Additionally, Ghosler handles both Free & Paid members content management efficiently.

Moreover, Ghosler supports popular Ghost widgets, including Images/Unsplash, Audio, Video, File, Toggle, Callout Card, and social media integrations like Twitter (X), YouTube, Vimeo, along with Button, Bookmark, and Blockquote features.


Ending the Preview

Once you\'re ready to begin publishing and want to clear out these starter posts, you can delete the Ghost staff user. Deleting an author will automatically remove all of their posts, leaving you with a clean blank canvas.

', + preview: + "We've crammed the most important information to help you get started with Ghost into this one post. It's your cheat-sheet to get started, and your shortcut to advanced features.", + content: + "

Hey there, welcome to your new home on the web!


Ghost

Ghost is an independent, open source app, which means you can customize absolutely everything. Inside the admin area, you'll find straightforward controls for changing themes, colors, navigation, logos and settings — so you can set your site up just how you like it. No technical knowledge required.


Ghosler

Ghosler is an open source project designed for those starting with Ghost or managing a small to moderate user base. It provides extensive control over newsletter settings and customization, enhancing your outreach with features like URL Click Tracking, Newsletter Feedback, Email Deliverability, and Email Open Rate Analytics. Additionally, Ghosler handles both Free & Paid members content management efficiently.

Moreover, Ghosler supports popular Ghost widgets, including Images/Unsplash, Audio, Video, File, Toggle, Callout Card, and social media integrations like Twitter (X), YouTube, Vimeo, along with Button, Bookmark, and Blockquote features.


Ending the Preview

Once you're ready to begin publishing and want to clear out these starter posts, you can delete the Ghost staff user. Deleting an author will automatically remove all of their posts, leaving you with a clean blank canvas.

", comments: 'https://bulletin.ghost.io/welcome/#ghost-comments', - featuredImage: 'https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=2874&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', - featuredImageCaption: 'Photo by Milad Fakurian / Unsplash', + featuredImage: + 'https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=2874&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + featuredImageCaption: + 'Photo by Milad Fakurian / Unsplash', latestPosts: [], - showPaywall: true + showPaywall: true, }, newsletter: { unsubscribeLink: 'https://bulletin.ghost.io/unsubscribe', subscription: 'https://bulletin.ghost.io/#/portal/account', - feedbackLikeLink: 'https://bulletin.ghost.io/#/feedback/60d14faa9e72bc002f16c727/1/?uuid=example', - feedbackDislikeLink: 'https://bulletin.ghost.io/#/feedback/60d14faa9e72bc002f16c727/0/?uuid=example' - } + feedbackLikeLink: + 'https://bulletin.ghost.io/#/feedback/60d14faa9e72bc002f16c727/1/?uuid=example', + feedbackDislikeLink: + 'https://bulletin.ghost.io/#/feedback/60d14faa9e72bc002f16c727/0/?uuid=example', + }, }; const customisations = await ProjectConfigs.newsletter(); @@ -296,22 +340,27 @@ export default class Newsletter { preview.show_featured_image = customisations.show_featured_image; preview.show_powered_by_ghost = customisations.show_powered_by_ghost; - preview.show_powered_by_ghosler = customisations.show_powered_by_ghosler; + preview.show_powered_by_ghosler = + customisations.show_powered_by_ghosler; if (customisations.show_latest_posts) { preview.post.latestPosts = [ { title: '5 ways to repurpose content like a professional creator', url: 'https://bulletin.ghost.io/5-ways-to-repurpose-content-like-a-professional-creator/', - preview: 'Ever wonder how the biggest creators publish so much content on such a consistent schedule? It\'s not magic, but once you understand how this tactic works, it\'ll feel like it is.', - featuredImage: 'https://images.unsplash.com/photo-1609761973820-17fe079a78dc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8c2VhcmNofDkzfHx3b3JraW5nfGVufDB8fHx8MTYyNTQ3Mzg2NA&ixlib=rb-1.2.1&q=80&w=2000' + preview: + "Ever wonder how the biggest creators publish so much content on such a consistent schedule? It's not magic, but once you understand how this tactic works, it'll feel like it is.", + featuredImage: + 'https://images.unsplash.com/photo-1609761973820-17fe079a78dc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxMTc3M3wwfDF8c2VhcmNofDkzfHx3b3JraW5nfGVufDB8fHx8MTYyNTQ3Mzg2NA&ixlib=rb-1.2.1&q=80&w=2000', }, { title: 'Customizing your brand and design settings', url: 'https://bulletin.ghost.io/design/', - preview: 'How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.', - featuredImage: 'https://static.ghost.org/v4.0.0/images/publishing-options.png' - } + preview: + 'How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.', + featuredImage: + 'https://static.ghost.org/v4.0.0/images/publishing-options.png', + }, ]; } @@ -323,4 +372,4 @@ export default class Newsletter { return preview; } -} \ No newline at end of file +} diff --git a/utils/widgets.js b/utils/widgets.js index 72b7cfd..1441632 100644 --- a/utils/widgets.js +++ b/utils/widgets.js @@ -1,14 +1,13 @@ // noinspection HtmlUnknownAttribute,CssOverwrittenProperties,JSJQueryEfficiency -import {inline} from 'css-inline'; import * as cheerio from 'cheerio'; +import { inline } from 'css-inline'; import probe from 'probe-image-size'; -import {minify} from 'html-minifier'; +import { minify } from 'html-minifier'; import Miscellaneous from './misc.js'; export default class Widgets { - /** * Handles adding Bookmarks, Video, Audio, File, YouTube, Twitter, Image/Unsplash cards. * @@ -38,7 +37,10 @@ export default class Widgets { await this.#unsplashOrImage($, postId, trackedLinks, isTracking); //trackedLinks: [], modifiedHtml: template - return {trackedLinks: Array.from(trackedLinks), modifiedHtml: this.#inlineAndMinify($)}; + return { + trackedLinks: Array.from(trackedLinks), + modifiedHtml: this.#inlineAndMinify($), + }; } /** @@ -48,7 +50,9 @@ export default class Widgets { */ static #bookmark($) { const bookmarkPublisher = $('.kg-bookmark-publisher'); - bookmarkPublisher.html(`${bookmarkPublisher.html()}`); + bookmarkPublisher.html( + `${bookmarkPublisher.html()}`, + ); $('.kg-bookmark-thumbnail').each(function () { const img = $(this).find('img'); @@ -56,7 +60,10 @@ export default class Widgets { if (imageUrl) { const currentStyle = $(this).attr('style') || ''; - $(this).attr('style', `${currentStyle} background-image: url('${imageUrl}');`); + $(this).attr( + 'style', + `${currentStyle} background-image: url('${imageUrl}');`, + ); } }); } @@ -73,7 +80,10 @@ export default class Widgets { const figure = $(this); let thumbnailUrl = figure.attr('data-kg-custom-thumbnail'); if (!thumbnailUrl) thumbnailUrl = figure.attr('data-kg-thumbnail'); - if (!thumbnailUrl) thumbnailUrl = 'https://img.spacergif.org/v1/1280x720/0a/spacer.png'; + if (!thumbnailUrl) { + thumbnailUrl = + 'https://img.spacergif.org/v1/1280x720/0a/spacer.png'; + } const videoContent = ` @@ -113,7 +123,9 @@ export default class Widgets { audioFigures.each(function () { const audioFigure = $(this); const fileName = audioFigure.find('.kg-audio-title').text().trim(); - const duration = Miscellaneous.formatDuration(audioFigure.find('.kg-audio-duration').text().trim()); + const duration = Miscellaneous.formatDuration( + audioFigure.find('.kg-audio-duration').text().trim(), + ); // this is pretty huge actually! const audioElement = ` @@ -194,9 +206,18 @@ export default class Widgets { const file = $(this); const metaData = file.find('.kg-file-card-metadata'); const fileTitle = file.find('.kg-file-card-title').text().trim(); - const fileCaption = file.find('.kg-file-card-caption').text().trim(); - const fileName = metaData.find('.kg-file-card-filename').text().trim(); - const fileSize = metaData.find('.kg-file-card-filesize').text().trim(); + const fileCaption = file + .find('.kg-file-card-caption') + .text() + .trim(); + const fileName = metaData + .find('.kg-file-card-filename') + .text() + .trim(); + const fileSize = metaData + .find('.kg-file-card-filesize') + .text() + .trim(); const fileElement = ` @@ -281,13 +302,17 @@ export default class Widgets { let trackedVideoLink = embedUrl; if (isYoutube) { - const videoId = (embedUrl.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/) || [])[1]; + const videoId = (embedUrl.match( + /youtube\.com\/embed\/([a-zA-Z0-9_-]+)/, + ) || [])[1]; if (videoId) { videoLink = `https://youtu.be/${videoId}`; trackedVideoLink = videoLink; } } else if (isVimeo) { - const videoId = (embedUrl.match(/player\.vimeo\.com\/video\/([a-zA-Z0-9_-]+)/) || [])[1]; + const videoId = (embedUrl.match( + /player\.vimeo\.com\/video\/([a-zA-Z0-9_-]+)/, + ) || [])[1]; if (videoId) { videoLink = `https://vimeo.com/${videoId}`; trackedVideoLink = videoLink; @@ -379,15 +404,19 @@ export default class Widgets { * @param {boolean} isTracking */ static async #gallery($, trackedLinks, isTracking) { - const galleryImages = $('.kg-gallery-card .kg-gallery-image img').toArray(); + const galleryImages = $( + '.kg-gallery-card .kg-gallery-image img', + ).toArray(); const promises = galleryImages.map(async (element) => { const image = $(element); const sourceUrl = image.attr('src'); const originalLink = Miscellaneous.getOriginalUrl(sourceUrl); - let dimensions = {width: 600, height: 0}; + let dimensions = { width: 600, height: 0 }; const probeSize = await probe(sourceUrl); - dimensions.height = Math.round(probeSize.height * (dimensions.width / probeSize.width)); + dimensions.height = Math.round( + probeSize.height * (dimensions.width / probeSize.width), + ); let anchorTrackableUrl = originalLink; if (isTracking) anchorTrackableUrl = sourceUrl; // if tracking, this already is a tracked link! @@ -419,7 +448,7 @@ export default class Widgets { const figure = $(element); let image = figure.find('img'); let imageUrl = image.attr('src'); - let dimensions = {width: 600, height: 0}; + let dimensions = { width: 600, height: 0 }; const imageParent = image.parent(); const wasInsideAnchor = imageParent.is('a'); @@ -434,7 +463,10 @@ export default class Widgets { imageUrl.searchParams.delete('w'); imageUrl.searchParams.delete('h'); - imageUrl.searchParams.set('w', (dimensions.width * 2).toFixed(0)); + imageUrl.searchParams.set( + 'w', + (dimensions.width * 2).toFixed(0), + ); imageUrl = imageUrl.href; } @@ -447,20 +479,28 @@ export default class Widgets { } // we need to remove the tracked links inside caption too! - $(caption).find('a').each(function () { - let anchorTag = $(this); - let href = anchorTag.attr('href'); - - // replacing happens after the links have been added for tracking. - // so, we need to remove these links, like unsplash & the caption. - if (href && href.includes('/track/link?')) { - anchorTag.attr('href', cleanImageUrl); - if (isTracking) trackedLinks.delete(Miscellaneous.getOriginalUrl(href)); - } - }); + $(caption) + .find('a') + .each(function () { + let anchorTag = $(this); + let href = anchorTag.attr('href'); + + // replacing happens after the links have been added for tracking. + // so, we need to remove these links, like unsplash & the caption. + if (href && href.includes('/track/link?')) { + anchorTag.attr('href', cleanImageUrl); + if (isTracking) { + trackedLinks.delete( + Miscellaneous.getOriginalUrl(href), + ); + } + } + }); const probeSize = await probe(cleanImageUrl); - dimensions.height = Math.round(probeSize.height * (dimensions.width / probeSize.width)); + dimensions.height = Math.round( + probeSize.height * (dimensions.width / probeSize.width), + ); let imageHtml = `${image.attr('alt')}`; @@ -468,7 +508,10 @@ export default class Widgets { imageHtml = `${imageHtml}`; } else { const trackedAnchorLink = isTracking - ? await Miscellaneous.addTrackingToUrl(cleanImageUrl, postId) + ? await Miscellaneous.addTrackingToUrl( + cleanImageUrl, + postId, + ) : cleanImageUrl; imageHtml = `${imageHtml}`; } @@ -495,12 +538,14 @@ export default class Widgets { */ static #inlineAndMinify($) { // a few things have been taken straight from Ghost's repo. - const originalImageSizes = $('img').get().map((image) => { - const src = image.attribs.src; - const width = image.attribs.width; - const height = image.attribs.height; - return {src, width, height}; - }); + const originalImageSizes = $('img') + .get() + .map((image) => { + const src = image.attribs.src; + const width = image.attribs.width; + const height = image.attribs.height; + return { src, width, height }; + }); const inlinedCssHtml = inline($.html(), { keep_style_tags: true, @@ -513,10 +558,16 @@ export default class Widgets { for (let i = 0; i < imageTags.length; i += 1) { if (imageTags[i].attribs.src === originalImageSizes[i].src) { - if (imageTags[i].attribs.width === 'auto' && originalImageSizes[i].width) { + if ( + imageTags[i].attribs.width === 'auto' && + originalImageSizes[i].width + ) { imageTags[i].attribs.width = originalImageSizes[i].width; } - if (imageTags[i].attribs.height === 'auto' && originalImageSizes[i].height) { + if ( + imageTags[i].attribs.height === 'auto' && + originalImageSizes[i].height + ) { imageTags[i].attribs.height = originalImageSizes[i].height; } } @@ -527,7 +578,9 @@ export default class Widgets { // convert figure and figcaption to div so that Outlook applies margins. // styles are already inlined at this point, so it's kinda fine to do this. - $('figure, figcaption').each((index, element) => !!(element.tagName = 'div')); + $('figure, figcaption').each( + (index, element) => !!(element.tagName = 'div'), + ); return minify($.html(), { minifyCSS: true, @@ -536,4 +589,4 @@ export default class Widgets { removeAttributeQuotes: true, }); } -} \ No newline at end of file +}