├── public ├── robots.txt └── styles │ └── tailwind.css ├── .prettierignore ├── tailwind.config.js ├── routes ├── preview.js ├── password.js ├── logs.js ├── login.js ├── analytics.js ├── index.js ├── track.js ├── published.js ├── settings.js └── newsletters.js ├── views ├── partials │ ├── common │ │ ├── footer.ejs │ │ ├── message.ejs │ │ └── header.ejs │ └── settings │ │ ├── profile.ejs │ │ ├── template.ejs │ │ ├── ghost.ejs │ │ ├── mail.ejs │ │ └── customise.ejs ├── errors │ └── 404.ejs ├── login.ejs ├── dashboard │ ├── logs.ejs │ ├── password.ejs │ ├── import-export.ejs │ ├── upload-template.ejs │ ├── newsletters.ejs │ ├── details.ejs │ ├── settings.ejs │ └── analytics.ejs └── index.ejs ├── .gitignore ├── .github └── workflows │ └── source-ci.yaml ├── .dockerignore ├── configuration └── config.production.json ├── Dockerfile ├── utils ├── models │ ├── stats.js │ ├── subscriber.js │ └── post.js ├── log │ ├── logger.js │ └── options.js ├── bitset.js ├── data │ ├── ops │ │ ├── links_queue.js │ │ └── emails_queue.js │ ├── transfers.js │ ├── files.js │ └── configs.js ├── api │ └── ghost.js ├── mail │ └── mailer.js └── misc.js ├── package.json ├── app.js ├── docker-install.sh ├── README.md └── LICENSE.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json 2 | **/*.md 3 | **/*.css 4 | **/*.yaml -------------------------------------------------------------------------------- /public/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./views/**/*.{html,js,ejs}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /routes/preview.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Newsletter from '../utils/newsletter.js'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', async (_, res) => { 7 | const preview = await Newsletter.preview(); 8 | const template = await Newsletter.renderTemplate(preview); 9 | res.set('Content-Type', 'text/html').send(template.modifiedHtml); 10 | }); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /views/partials/common/footer.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /routes/password.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import ProjectConfigs from '../utils/data/configs.js'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', (req, res) => { 7 | res.render('dashboard/password'); 8 | }); 9 | 10 | router.post('/', async (req, res) => { 11 | const formData = req.body; 12 | const result = await ProjectConfigs.update(formData, true); 13 | res.render('dashboard/password', result); 14 | }); 15 | 16 | export default router; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ project files 2 | .idea 3 | *.iml 4 | 5 | # vs code 6 | .vscode 7 | 8 | # logs 9 | .logs 10 | *.log 11 | .DS_Store 12 | 13 | # project specific data files folder 14 | files 15 | 16 | # auto gen 17 | node_modules 18 | 19 | # debug configuration 20 | configuration/config.debug.json 21 | configuration/config.local.json 22 | 23 | # custom template used for tests 24 | custom-template.ejs 25 | 26 | # helper script for docker publish 27 | docker-publish.sh 28 | 29 | # internal backed up files for quick tests, `.bak` extensions 30 | *.bak -------------------------------------------------------------------------------- /routes/logs.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Files from '../utils/data/files.js'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/', (req, res) => res.redirect('/logs/debug')); 7 | 8 | router.get('/:type', async (req, res) => { 9 | const logType = req.params.type; 10 | res.render('dashboard/logs', await Files.logs(logType ?? 'debug')); 11 | }); 12 | 13 | router.get('/clear/:type', async (req, res) => { 14 | const logType = req.params.type; 15 | await Files.clearLogs(logType); 16 | res.redirect(`/logs/${logType}`); 17 | }); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /.github/workflows/source-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Source CI 2 | 3 | on: 4 | workflow_dispatch: # for manual runs 5 | pull_request: 6 | types: [ opened, synchronize ] 7 | 8 | jobs: 9 | build-and-test: 10 | name: Run Lint 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 18 21 | 22 | - name: Install Dependencies 23 | run: npm install 24 | 25 | - name: Run Prettier 26 | run: npm run lint -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # build files 2 | .idea/ 3 | .vscode/ 4 | node_modules/ 5 | 6 | # local development files 7 | .logs/ 8 | *.log 9 | files/ 10 | 11 | # spam 12 | .DS_Store 13 | 14 | # git 15 | .git 16 | .gitignore 17 | 18 | # prettier 19 | .prettierignore 20 | 21 | # ci 22 | .github 23 | 24 | # markdown 25 | LICENSE.md 26 | README.md 27 | 28 | # custom template 29 | custom-template.ejs 30 | 31 | # tailwind files 32 | tailwind.config.js 33 | public/styles/tailwind.css 34 | 35 | # installer 36 | docker-install.sh 37 | 38 | # local, debug config files 39 | configuration/config.debug.json 40 | configuration/config.local.json -------------------------------------------------------------------------------- /routes/login.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Miscellaneous from '../utils/misc.js'; 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/login', (req, res) => { 7 | res.render('login', { redirect: req.query.redirect ?? '' }); 8 | }); 9 | 10 | router.post('/login', async (req, res) => { 11 | const authStatus = await Miscellaneous.authenticated(req); 12 | if (authStatus.level === 'success') { 13 | let redirect = req.body.redirect; 14 | if (!redirect) redirect = '/'; 15 | 16 | return res.redirect(redirect); 17 | } else return res.render('login', authStatus); 18 | }); 19 | 20 | router.get('/logout', (req, res) => { 21 | req.session = null; 22 | res.redirect('/'); 23 | }); 24 | 25 | export default router; 26 | -------------------------------------------------------------------------------- /configuration/config.production.json: -------------------------------------------------------------------------------- 1 | { 2 | "ghosler": { 3 | "url": "", 4 | "port": 2369, 5 | "auth": { 6 | "user": "ghosler", 7 | "pass": "21232f297a57a5a743894a0e4a801fc3" 8 | } 9 | }, 10 | "ghost": { 11 | "url": "", 12 | "key": "", 13 | "secret": "", 14 | "version": "v5.0" 15 | }, 16 | "newsletter": { 17 | "center_title": false, 18 | "show_excerpt": false, 19 | "footer_content": "", 20 | "show_comments": true, 21 | "show_feedback": true, 22 | "show_subscription": true, 23 | "show_featured_image": true, 24 | "show_powered_by_ghost": true, 25 | "show_powered_by_ghosler": true, 26 | "track_links": true 27 | }, 28 | "custom_template": { 29 | "enabled": false 30 | }, 31 | "mail": [] 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18-alpine AS deps 3 | 4 | # Set working directory 5 | WORKDIR /usr/src/app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install only production dependencies 11 | RUN npm ci --omit-dev 12 | 13 | # Build stage renamed for clarity, no actual build commands though 14 | FROM node:18-alpine AS runner 15 | 16 | # Set working directory 17 | WORKDIR /usr/src/app 18 | 19 | # Copy node_modules from 'deps' stage 20 | COPY --from=deps /usr/src/app/node_modules ./node_modules 21 | 22 | # Copy the rest of your application code 23 | COPY . . 24 | 25 | # Label 26 | LABEL author="@ItzNotABug" 27 | 28 | # Install PM2 globally 29 | RUN npm install pm2 -g 30 | 31 | # Start your app 32 | CMD ["pm2-runtime", "start", "app.js", "--name", "ghosler-app"] 33 | -------------------------------------------------------------------------------- /routes/analytics.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Ghost from '../utils/api/ghost.js'; 3 | import Files from '../utils/data/files.js'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | res.render('dashboard/analytics', await Files.all()); 9 | }); 10 | 11 | router.post('/delete/:postId', async (req, res) => { 12 | const postId = req.params.postId; 13 | const result = await Files.delete(postId); 14 | res.send(result); 15 | }); 16 | 17 | router.get('/details/:postId', async (req, res) => { 18 | const postId = req.params.postId; 19 | const post = await Files.get(postId); 20 | const postSentiments = await new Ghost().postSentiments(postId); 21 | res.render('dashboard/details', { post, postSentiments }); 22 | }); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /views/errors/404.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Not Found - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 |

404

15 |

16 | The page you're searching for seems to be missing. 17 | It might have been removed or might have had its name changed. 18 |

19 | 20 |

21 | Please check the URL for any typos or 22 |
return to the 23 | homepage to find your way. 24 |

25 |
26 | 27 | <%- include('../partials/common/footer.ejs') %> 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import ProjectConfigs from '../utils/data/configs.js'; 3 | import Miscellaneous from '../utils/misc.js'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (_, res) => { 8 | const configs = await ProjectConfigs.all(); 9 | 10 | if ( 11 | configs.ghosler.auth.user === 'ghosler' && 12 | configs.ghosler.auth.pass === Miscellaneous.hash('admin') 13 | ) { 14 | res.render('index', { 15 | level: 'error', 16 | message: 17 | 'Update your username and password.

Default -
Username: ghosler, Password: admin', 18 | }); 19 | } else if (configs.ghost.url === '' || configs.ghost.key === '') { 20 | res.render('index', { 21 | level: 'error', 22 | message: 'Set up your Ghost Site Url & add an Admin API Key.', 23 | }); 24 | } else if (configs.mail.length === 0) { 25 | res.render('index', { 26 | level: 'error', 27 | message: 'Add email credentials to send newsletters.', 28 | }); 29 | } else res.render('index'); 30 | }); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /views/partials/common/message.ejs: -------------------------------------------------------------------------------- 1 | 2 | <% if ((typeof message !== 'undefined' && message && message.toString().length > 0)) { %> 3 |
4 |
5 |

<%- message; %>

6 |
7 |
8 | 9 | 24 | <% } %> -------------------------------------------------------------------------------- /utils/models/stats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents statistics related to a Post. 3 | */ 4 | export default class Stats { 5 | /** 6 | * Creates an instance of Stats. 7 | * 8 | * @param {number} [members] - Count of members who should receive the newsletter. 9 | * @param {number} [emailsSent=0] - The count of emails sent related to this Post. 10 | * @param {string} [emailsOpened=''] - The statistics of opened emails for this Post. 11 | * @param {string} [newsletterName=''] - The name of the post's newsletter. 12 | * @param {string} [newsletterStatus='na'] - The status of the post's newsletter. 13 | * @param {{[key: string]: number}[]} [postContentTrackedLinks=[]] - The urls that will be tracked when a user clicks on them in the email. 14 | */ 15 | constructor( 16 | members = 0, 17 | emailsSent = 0, 18 | emailsOpened = '', 19 | newsletterName = '', 20 | newsletterStatus = 'na', 21 | postContentTrackedLinks = [], 22 | ) { 23 | this.members = members; 24 | this.emailsSent = emailsSent; 25 | this.emailsOpened = emailsOpened; 26 | this.newsletterName = newsletterName; 27 | this.newsletterStatus = newsletterStatus; 28 | this.postContentTrackedLinks = postContentTrackedLinks; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /routes/track.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Miscellaneous from '../utils/misc.js'; 3 | import ProjectConfigs from '../utils/data/configs.js'; 4 | import LinksQueue from '../utils/data/ops/links_queue.js'; 5 | import EmailsQueue from '../utils/data/ops/emails_queue.js'; 6 | 7 | const router = express.Router(); 8 | const linksQueue = new LinksQueue(); 9 | const statsQueue = new EmailsQueue(); 10 | 11 | // track email opens. 12 | router.get('/track/pixel.png', async (req, res) => { 13 | if (req.query && req.query.uuid) statsQueue.add(req.query.uuid); 14 | 15 | const pixel = Miscellaneous.trackingPixel(); 16 | 17 | res.writeHead(200, { 18 | 'Content-Type': 'image/png', 19 | 'Content-Length': pixel.length, 20 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 21 | Pragma: 'no-cache', 22 | Expires: '0', 23 | }); 24 | 25 | res.end(pixel); 26 | }); 27 | 28 | // track link clicks. 29 | router.get('/track/link', async (req, res) => { 30 | if (req.query && req.query.postId && req.query.redirect) { 31 | linksQueue.add(req.query.postId, req.query.redirect); 32 | res.redirect(req.query.redirect); 33 | } else { 34 | // redirect to main ghost blog. 35 | ProjectConfigs.ghost().then((cfg) => res.redirect(cfg.url)); 36 | } 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /views/partials/settings/profile.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

Profile

5 | 7 | 8 | 9 |
10 | 11 | 23 |
24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghosler", 3 | "description": "Send newsletter emails to your members, using your own email credentials!", 4 | "version": "0.98.4", 5 | "main": "app.js", 6 | "type": "module", 7 | "author": "@itznotabug", 8 | "prettier": { 9 | "tabWidth": 4, 10 | "singleQuote": true 11 | }, 12 | "scripts": { 13 | "lint": "prettier . --check", 14 | "format": "prettier . --write", 15 | "dev": "nodemon -e js app.js", 16 | "cleanstart": "npm run buildcss && npm run dev", 17 | "buildcss": "npx tailwindcss -i ./public/styles/tailwind.css -o ./public/styles/style.css --minify" 18 | }, 19 | "dependencies": { 20 | "@extractus/oembed-extractor": "^4.0.2", 21 | "@tryghost/admin-api": "^1.13.11", 22 | "archiver": "^7.0.1", 23 | "cheerio": "^1.0.0-rc.12", 24 | "cookie-session": "^2.1.0", 25 | "css-inline": "^0.11.2", 26 | "ejs": "^3.1.10", 27 | "express": "^4.19.2", 28 | "express-fileupload": "^1.5.0", 29 | "extract-zip": "^2.0.1", 30 | "he": "^1.2.0", 31 | "html-minifier": "^4.0.0", 32 | "jsonwebtoken": "^9.0.0", 33 | "nodemailer": "^6.9.13", 34 | "probe-image-size": "^7.2.3", 35 | "winston": "^3.13.0" 36 | }, 37 | "devDependencies": { 38 | "nodemon": "^3.1.0", 39 | "prettier": "^3.3.0", 40 | "tailwindcss": "^3.4.1", 41 | "@types/node": "^20.14.10", 42 | "@types/archiver": "^6.0.2", 43 | "@types/cheerio": "^0.22.35", 44 | "@types/express-fileupload": "^1.5.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Miscellaneous from './utils/misc.js'; 3 | import ProjectConfigs from './utils/data/configs.js'; 4 | import { logDebug, logTags } from './utils/log/logger.js'; 5 | 6 | // route imports 7 | import logs from './routes/logs.js'; 8 | import index from './routes/index.js'; 9 | import login from './routes/login.js'; 10 | import track from './routes/track.js'; 11 | import preview from './routes/preview.js'; 12 | import settings from './routes/settings.js'; 13 | import password from './routes/password.js'; 14 | import published from './routes/published.js'; 15 | import analytics from './routes/analytics.js'; 16 | import newsletters from './routes/newsletters.js'; 17 | 18 | const expressApp = express(); 19 | await Miscellaneous.setup(expressApp); 20 | 21 | // define routes 22 | logDebug('Express', 'Setting routes...'); 23 | expressApp.use(track); 24 | expressApp.use(login); 25 | expressApp.use('/', index); 26 | expressApp.use('/logs', logs); 27 | expressApp.use('/preview', preview); 28 | expressApp.use('/settings', settings); 29 | expressApp.use('/password', password); 30 | expressApp.use('/analytics', analytics); 31 | expressApp.use('/published', published); 32 | expressApp.use('/newsletters', newsletters); 33 | 34 | // custom 404 after all the pages have been set up. 35 | expressApp.use((req, res) => res.status(404).render('errors/404')); 36 | 37 | logDebug(logTags.Express, 'Routes configured!'); 38 | 39 | // start the app with given port! 40 | ProjectConfigs.ghosler().then((configs) => { 41 | expressApp.listen(configs.port); 42 | logDebug(logTags.Express, 'App started successfully!'); 43 | logDebug(logTags.Express, '============================'); 44 | }); 45 | -------------------------------------------------------------------------------- /utils/log/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import options from './options.js'; 3 | 4 | const winstonLogger = winston.createLogger(options); 5 | 6 | // Log Tags for better identification. 7 | export const logTags = Object.freeze({ 8 | Ghost: 'Ghost', 9 | Files: 'Files', 10 | Stats: 'Stats', 11 | Configs: 'Configs', 12 | Express: 'Express', 13 | Newsletter: 'Newsletter', 14 | }); 15 | 16 | /** 17 | * Logs a message to console only. 18 | * 19 | * @param {string} tag - The tag for the message. 20 | * @param {string} message - The message to be logged. 21 | */ 22 | export function logToConsole(tag, message) { 23 | console.log(`${tag}: ${message}`); 24 | } 25 | 26 | /** 27 | * Logs a debug message. 28 | * 29 | * @param {string} tag - The tag for the message. 30 | * @param {string} message - The message to be logged. 31 | * @param {boolean} [printToConsole] - If true, the message will only be logged to the console as well. 32 | */ 33 | export function logDebug(tag, message, printToConsole = true) { 34 | log(tag, message, 'debug'); 35 | if (printToConsole) logToConsole(tag, message); 36 | } 37 | 38 | /** 39 | * Logs an error message. 40 | * 41 | * @param {string} tag - The tag for the message. 42 | * @param {Object} error - The error to be logged. 43 | */ 44 | export function logError(tag, error) { 45 | log(tag, error, 'error'); 46 | logToConsole(tag, error.toString()); 47 | } 48 | 49 | // Log given message with winston. 50 | function log(tag, message, level) { 51 | let newMessage; 52 | if (message instanceof Error) newMessage = message.stack; 53 | else newMessage = message.toString(); 54 | 55 | const messageWithTag = `${tag}: ${newMessage}`; 56 | 57 | if (level === 'debug') winstonLogger.debug(messageWithTag); 58 | else if (level === 'error') winstonLogger.error(messageWithTag); 59 | } 60 | -------------------------------------------------------------------------------- /views/partials/settings/template.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Custom Template Settings

4 | 6 | 7 | 8 |
9 | 10 | 33 |
34 | -------------------------------------------------------------------------------- /utils/bitset.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a BitSet, a data structure to handle a set of bits (0s and 1s). 3 | */ 4 | export default class BitSet { 5 | /** 6 | * Creates a BitSet. 7 | * 8 | * @param {number|string} input - The size of the bitset (number of bits) or a string representing the bitset. 9 | */ 10 | constructor(input) { 11 | if (typeof input === 'string') { 12 | this.bits = input.split('').map(Number); 13 | } else if (typeof input === 'number') { 14 | this.bits = new Array(input).fill(0); 15 | } else { 16 | throw new Error('Invalid input type'); 17 | } 18 | } 19 | 20 | /** 21 | * Counts the number of bits set to 1 in the BitSet. 22 | * 23 | * @returns {number} The count of bits set to 1. 24 | */ 25 | popCount() { 26 | return this.bits.filter((b) => b === 1).length; 27 | } 28 | 29 | /** 30 | * Sets the value of the bit at a specific index. 31 | * 32 | * @param {number} index - The index of the bit to set. 33 | * @param {number} value - The value to set at the index (0 or 1). 34 | */ 35 | set(index, value) { 36 | if (index < 0 || index >= this.bits.length) { 37 | throw new Error('Index out of range'); 38 | } 39 | this.bits[index] = value ? 1 : 0; 40 | } 41 | 42 | /** 43 | * Gets the value of the bit at a specific index. 44 | * 45 | * @param {number} index - The index of the bit to get. 46 | * @returns {number} The value of the bit at the specified index, or -1 if the index is out of range. 47 | */ 48 | get(index) { 49 | if (index < 0 || index >= this.bits.length) { 50 | return -1; 51 | } 52 | return this.bits[index]; 53 | } 54 | 55 | /** 56 | * Returns a string representation of the BitSet. 57 | * 58 | * @returns {string} The string representation of the BitSet. 59 | */ 60 | toString() { 61 | return this.bits.join(''); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /routes/published.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Ghost from '../utils/api/ghost.js'; 3 | import Post from '../utils/models/post.js'; 4 | import Miscellaneous from '../utils/misc.js'; 5 | import Newsletter from '../utils/newsletter.js'; 6 | import { logDebug, logTags } from '../utils/log/logger.js'; 7 | 8 | const router = express.Router(); 9 | 10 | router.post('/', async (req, res) => { 11 | if (!req.body || !req.body.post || !req.body.post.current) { 12 | return res 13 | .status(400) 14 | .json({ message: 'Post content seems to be missing!' }); 15 | } 16 | 17 | // check if the request is authenticated. 18 | const isSecure = await Miscellaneous.isPostSecure(req); 19 | if (!isSecure) { 20 | return res.status(401).json({ message: 'Invalid Authorization.' }); 21 | } 22 | 23 | // check if contains the ignore tag. 24 | if (Post.containsIgnoreTag(req.body)) { 25 | return res 26 | .status(200) 27 | .json({ message: 'Post contains `ghosler_ignore` tag, ignoring.' }); 28 | } 29 | 30 | logDebug(logTags.Newsletter, 'Post received via webhook.'); 31 | 32 | const post = Post.make(req.body); 33 | const newslettersCount = await new Ghost().newslettersCount(); 34 | const created = await post.save(newslettersCount > 1); 35 | 36 | if (!created) { 37 | res.status(500).json({ 38 | message: 39 | 'The post data could not be saved, or emails for this post have already been sent.', 40 | }); 41 | } else { 42 | if (newslettersCount === 1) { 43 | Newsletter.send(post).then(); 44 | res.status(200).json({ 45 | message: 'Newsletter will be sent shortly.', 46 | }); 47 | } else { 48 | // we probably have multiple active newsletters or none at all, so just save the post. 49 | res.status(200).json({ 50 | message: 51 | 'Multiple or No active Newsletters found, current Post saved for manual action.', 52 | }); 53 | } 54 | } 55 | }); 56 | 57 | export default router; 58 | -------------------------------------------------------------------------------- /docker-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define color codes 4 | NC='\033[0m' 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | BOLD_GREEN='\033[1;32m' 8 | 9 | # Set default values 10 | DEFAULT_PORT=2369 11 | DEFAULT_CONTAINER_NAME="ghosler" 12 | 13 | echo "" 14 | echo -e "${BOLD_GREEN}Note: If you are UPDATING, use the same PORT & CONTAINER NAME!${NC}" 15 | echo "" 16 | 17 | # Prompt for port number with default 18 | echo -e "${GREEN}Provide a port for Ghosler [Default: $DEFAULT_PORT]${NC} : " 19 | read -r PORT 20 | PORT=${PORT:-$DEFAULT_PORT} 21 | 22 | # Prompt for container name with default 23 | echo -e "${GREEN}Provide a name for Ghosler Container [Default: $DEFAULT_CONTAINER_NAME]${NC} : " 24 | read -r CONTAINER_NAME 25 | CONTAINER_NAME=${CONTAINER_NAME:-$DEFAULT_CONTAINER_NAME} 26 | 27 | # Fetch the releases from GitHub API 28 | releases=$(curl -s "https://api.github.com/repos/itznotabug/ghosler/releases") 29 | 30 | # latest version 31 | LATEST_VERSION="" 32 | 33 | # Loop through each line in the releases 34 | while read -r line; do 35 | # Check if the line contains "tag_name" and LATEST_VERSION is not set 36 | if [[ $line == *"tag_name"* && -z $LATEST_VERSION ]]; then 37 | # Extract the version from the line 38 | LATEST_VERSION=$(echo "$line" | cut -d '"' -f 4) 39 | break 40 | fi 41 | done <<< "$releases" 42 | 43 | # Check if a version was found 44 | if [[ -z "$LATEST_VERSION" ]]; then 45 | LATEST_VERSION="0.94" # default to the docker supported version. 46 | fi 47 | 48 | echo -e "${GREEN}Starting Ghosler Docker installation...${NC}" 49 | echo "" 50 | 51 | # Stop the container silently if it's running in case of an update. 52 | docker stop "$CONTAINER_NAME" > /dev/null 2>&1 53 | 54 | # Run the container with latest version. 55 | if ! docker run --rm --name "$CONTAINER_NAME" -d -p "$PORT":2369 -v "${CONTAINER_NAME}"-logs:/usr/src/app/.logs -v "${CONTAINER_NAME}"-analytics:/usr/src/app/files -v "${CONTAINER_NAME}"-configuration:/usr/src/app/configuration itznotabug/ghosler:"${LATEST_VERSION}"; then 56 | echo "" 57 | echo -e "${RED}Error: Failed to start Ghosler. Please check the Docker logs for more details.${NC}" >&2 58 | exit 1 59 | fi 60 | 61 | echo "" 62 | echo -e "${GREEN}Ghosler has been successfully installed and started.${NC}" -------------------------------------------------------------------------------- /utils/log/options.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | /** 4 | * Returns the current date and time in the format [YYYY-MM-DD HH:MM:SS TZ]. 5 | * 6 | * @returns {string} The formatted timestamp with timezone. 7 | */ 8 | const timeStamp = () => { 9 | let currentDate = new Date(); 10 | return currentDate.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; 11 | }; 12 | 13 | /** 14 | * `logFormat` function that constructs a custom logging format. 15 | * 16 | * The format is structured as follows: 17 | * `[YYYY-MM-DD HH:MM:SS] LOG_LEVEL: Log message` 18 | * 19 | * Example: 20 | * `[2023-08-28 11:26:55] DEBUG: This is a debug message.` 21 | * 22 | * @returns {Function} - A Winston log formatter function. 23 | */ 24 | const logFormat = () => { 25 | return winston.format.printf((log) => { 26 | return `[${timeStamp()}] => [${log.level.toUpperCase()}] => ${log.message}`; 27 | }); 28 | }; 29 | 30 | /** 31 | * `filterOnly` creates a format filter that allows logging only for the specified level. 32 | * 33 | * @param {string} level - The log level to be filtered. 34 | * @returns {Function} - A Winston format function that filters logs based on the provided level. 35 | */ 36 | const filterOnly = (level) => { 37 | return winston.format((log) => { 38 | if (log.level === level) return log; 39 | })(); 40 | }; 41 | 42 | /** 43 | * `transport` creates a file transport configuration for the specified log level. 44 | * 45 | * @param {string} level - The log level for which the file transport should be configured. 46 | * @returns {winston.FileTransportInstance} - A Winston file transport instance configured for the specified level. 47 | */ 48 | const transport = (level) => 49 | new winston.transports.File({ 50 | level: level, 51 | dirname: '.logs', 52 | filename: `${level}.log`, 53 | format: filterOnly(level), 54 | }); 55 | 56 | /** 57 | * `options` contains the configurations for the logger. 58 | * 59 | * - `format`: The log format to use. It's defined by the `logFormat` function. 60 | * - `transports`: An array of transport configurations. In this case, it is set up for 'debug' and 'error' levels. 61 | */ 62 | const options = { 63 | format: logFormat(), 64 | transports: [transport('debug'), transport('error')], 65 | }; 66 | 67 | export default options; 68 | -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('./partials/common/header.ejs', {showLogout: false}) %> 12 | 13 |
14 | 15 | <%- include('./partials/common/message.ejs') %> 16 | 17 |
18 |

Login

19 |
20 | 21 |
22 | 23 | <% if (typeof redirect !== 'undefined' && redirect) { %> 24 | 27 | <% } %> 28 | 29 |
30 | 31 | 33 |
34 | 35 |
36 | 37 | 39 |
40 | 41 | 45 |
46 |
47 |
48 | 49 | <%- include('./partials/common/footer.ejs') %> 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /views/dashboard/logs.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logs - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | <% const logLevel = level.charAt(0).toUpperCase() + level.slice(1); %> 15 | 16 | 25 | 26 |
27 |
28 |

Log level: <%= logLevel %>

29 | <% if (content) { %> 30 | 37 | <% } %> 38 |
39 | 40 | <% if (content) { %> 41 |

Displayed logs are sorted in reverse chronological order, with the most recent 42 | entries appearing at the top.

43 |
44 |
<%= content; %>
45 |
46 | <% } else { %> 47 |

No log content available.

48 | <% } %> 49 |
50 |
51 | 52 | <%- include('../partials/common/footer.ejs') %> 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /views/dashboard/password.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Password - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | 23 | 24 | <%- include('../partials/common/message.ejs') %> 25 | 26 |
27 |
28 | 29 | 31 |
32 | 33 |
34 | 35 | 37 |
38 | 39 |
40 | 41 | 43 |
44 | 45 |
46 | 50 |
51 |
52 |
53 | 54 | <%- include('../partials/common/footer.ejs') %> 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /utils/models/subscriber.js: -------------------------------------------------------------------------------- 1 | import Ghost from '../api/ghost.js'; 2 | import Miscellaneous from '../misc.js'; 3 | 4 | /** 5 | * A class that represents a user on the site who has enabled receiving the newsletters via email. 6 | */ 7 | export default class Subscriber { 8 | constructor( 9 | uuid, 10 | name, 11 | email, 12 | status, 13 | created, 14 | newsletters = [], 15 | subscriptions = [], 16 | ) { 17 | this.uuid = uuid; 18 | this.name = name; 19 | this.email = email; 20 | this.status = status; 21 | this.newsletters = newsletters; 22 | this.subscriptions = subscriptions; 23 | this.created = Miscellaneous.formatDate(created); 24 | 25 | if (status === 'comped') this.status = 'complimentary'; 26 | } 27 | 28 | /** 29 | * Static utility method to generate an instance of Subscriber from a json object. 30 | * 31 | * @param {Object} jsonObject 32 | * @returns {Subscriber} 33 | */ 34 | static make(jsonObject) { 35 | return new Subscriber( 36 | jsonObject.uuid, 37 | jsonObject.name, 38 | jsonObject.email, 39 | jsonObject.status, 40 | jsonObject.created_at, 41 | jsonObject.newsletters, 42 | jsonObject.subscriptions, 43 | ); 44 | } 45 | 46 | /** 47 | * Check if this is a paying subscriber. 48 | * 49 | * @param {string[]} tierIds The tier ids to check against. 50 | * @returns {boolean} True if this is a paying member & has an active subscription. 51 | */ 52 | isPaying(tierIds) { 53 | const hasTier = this.subscriptions.some((subscription) => 54 | tierIds.includes(subscription.tier.id), 55 | ); 56 | 57 | // possible values are 'active', 'expired', 'canceled'. 58 | // also see why we use the first subscription object: 59 | // https://forum.ghost.org/t/one-tier-or-multiple-tiers-per-user/25848/2 60 | return hasTier && this.subscriptions[0].status === 'active'; 61 | } 62 | 63 | /** 64 | * Check if this subscriber is subscribed to a given newsletter id. 65 | * 66 | * @param {string|null} newsletterId The id of the newsletter to check against. 67 | * @returns {boolean} True if the subscriber has subscribed to the given newsletter id, or true by default if newsletterId is null. 68 | */ 69 | isSubscribedTo(newsletterId = null) { 70 | // probably no/one newsletter exists. 71 | if (newsletterId === null) return true; 72 | else if (newsletterId === Ghost.genericNewsletterItem.id) return true; 73 | else 74 | return this.newsletters.some( 75 | (newsletter) => newsletter.id === newsletterId, 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /views/dashboard/import-export.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Upload Template - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | 21 | 22 | <%- include('../partials/common/message.ejs') %> 23 | 24 |
26 |
27 | 28 |
29 | 30 | 31 | 37 |
38 |
39 | 40 |
41 | 42 |
43 | 47 | 48 | 50 | Export Current Settings 51 | 52 | 53 |

54 | Note: Uploading an import file will override your analytics & 55 | configurations! You'll also need to recreate your webhook on Ghost if the backup is being restored on a 56 | new domain! 57 |

58 | 59 |

THIS ACTION IS IRREVERSIBLE!

60 |
61 |
62 |
63 | 64 | <%- include('../partials/common/footer.ejs') %> 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /views/dashboard/upload-template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Upload Template - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | 23 | 24 | <%- include('../partials/common/message.ejs') %> 25 | 26 |
28 |
29 | 30 |
31 | 33 | 39 |
40 |
41 | 42 |
43 | 44 |
45 | 49 | 50 | <% if (customTemplateExists) { %> 51 | 53 | Download Current Template 54 | 55 | <% } %> 56 | 57 |

Note: Uploading a new custom template overrides the 58 | previous one.

59 |
60 |
61 |
62 | 63 | <%- include('../partials/common/footer.ejs') %> 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /utils/data/ops/links_queue.js: -------------------------------------------------------------------------------- 1 | import Files from '../files.js'; 2 | import { logDebug, logError, logTags } from '../../log/logger.js'; 3 | 4 | /** 5 | * A queue class for batching and processing updates to links click tracking statistics. 6 | */ 7 | export default class LinksQueue { 8 | /** 9 | * Creates a new Queue instance. 10 | * 11 | * @param {number} [delay=10000] - The delay in milliseconds before processing the queue. 12 | */ 13 | constructor(delay = 10000) { 14 | this.timer = null; 15 | this.delay = delay; 16 | 17 | /** @type {Map>} */ 18 | this.queue = new Map(); 19 | } 20 | 21 | /** 22 | * Adds a link stat update to the queue. 23 | * 24 | * @param {string} postId - The ID of the post. 25 | * @param {string} url - The URL whose count needs to be updated. 26 | */ 27 | add(postId, url) { 28 | if (!this.queue.has(postId)) this.queue.set(postId, new Map()); 29 | 30 | const postLinks = this.queue.get(postId); 31 | 32 | // Increment the count for the specific link 33 | if (!postLinks.has(url)) postLinks.set(url, 1); 34 | else { 35 | let currentCount = postLinks.get(url); 36 | postLinks.set(url, currentCount + 1); 37 | } 38 | 39 | // Reset the timer 40 | clearTimeout(this.timer); 41 | this.timer = setTimeout(() => this.#processQueue(), this.delay); 42 | } 43 | 44 | /** 45 | * Internal method to process the queued updates. 46 | */ 47 | async #processQueue() { 48 | const updatePromises = []; 49 | 50 | // Iterate over each post and its associated links and counts 51 | for (const [postId, urlsMap] of this.queue) { 52 | updatePromises.push(this.#updateFile(postId, urlsMap)); 53 | } 54 | 55 | await Promise.allSettled(updatePromises); 56 | 57 | this.queue.clear(); 58 | } 59 | 60 | /** 61 | * Internal method to update the file with the queued changes. 62 | * 63 | * @param {string} postId - The ID of the post. 64 | * @param {Map} urlStats - A map of links and their updated click counts. 65 | */ 66 | async #updateFile(postId, urlStats) { 67 | try { 68 | const post = await Files.get(postId); 69 | if (!post) return; 70 | 71 | post.stats.postContentTrackedLinks.forEach((linkObject) => { 72 | const linkUrl = Object.keys(linkObject)[0]; 73 | if (urlStats.has(linkUrl)) 74 | linkObject[linkUrl] += urlStats.get(linkUrl); 75 | }); 76 | 77 | const saved = await Files.create(post, true); 78 | if (saved) { 79 | logDebug( 80 | logTags.Stats, 81 | `Batched link click tracking updated for post: ${post.title}.`, 82 | ); 83 | } 84 | } catch (error) { 85 | logError(logTags.Stats, error); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /views/partials/settings/ghost.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

Ghost Settings

5 | 7 | 8 | 9 |
10 | 11 | 46 |
47 | -------------------------------------------------------------------------------- /utils/data/ops/emails_queue.js: -------------------------------------------------------------------------------- 1 | import Files from '../files.js'; 2 | import BitSet from '../../bitset.js'; 3 | import Miscellaneous from '../../misc.js'; 4 | import { logDebug, logError, logTags } from '../../log/logger.js'; 5 | 6 | /** 7 | * A queue class for batching and processing updates to tracking statistics. 8 | */ 9 | export default class EmailsQueue { 10 | /** 11 | * Creates a new Queue instance. 12 | * 13 | * @param {number} [delay=10000] - The delay in milliseconds before processing the queue. 14 | */ 15 | constructor(delay = 10000) { 16 | this.timer = null; 17 | this.delay = delay; 18 | 19 | /**@type {Map>} */ 20 | this.queue = new Map(); 21 | } 22 | 23 | /** 24 | * Adds an update to the queue. 25 | * 26 | * @param {string} encodedUUID - The base64 encoded UUID consisting of postId and memberIndex. 27 | */ 28 | add(encodedUUID) { 29 | const uuid = Miscellaneous.decode(encodedUUID); 30 | const [postId, memberIndex] = uuid.split('_'); 31 | 32 | if (!this.queue.has(postId)) { 33 | this.queue.set(postId, new Set()); 34 | } 35 | 36 | this.queue.get(postId).add(parseInt(memberIndex)); 37 | 38 | clearTimeout(this.timer); 39 | this.timer = setTimeout(() => this.#processQueue(), this.delay); 40 | } 41 | 42 | /** 43 | * Internal method to process the queued updates. 44 | */ 45 | async #processQueue() { 46 | const updatePromises = []; 47 | for (const [postId, memberIndexes] of this.queue) { 48 | updatePromises.push(this.#updateFile(postId, memberIndexes)); 49 | } 50 | 51 | await Promise.allSettled(updatePromises); 52 | 53 | this.queue.clear(); 54 | } 55 | 56 | /** 57 | * Internal method to update the file with the queued changes. 58 | * 59 | * @param {string} postId - The ID of the post. 60 | * @param {Set} memberIndexes - Array of member indexes to be updated. 61 | */ 62 | async #updateFile(postId, memberIndexes) { 63 | try { 64 | const post = await Files.get(postId); 65 | if (!post) return; 66 | 67 | let requiresFileUpdate = false; 68 | const bitSet = new BitSet(post.stats.emailsOpened); 69 | memberIndexes.forEach((index) => { 70 | if (bitSet.get(index) === 0) { 71 | bitSet.set(index, 1); 72 | requiresFileUpdate = true; 73 | } 74 | }); 75 | 76 | if (!requiresFileUpdate) return; 77 | 78 | post.stats.emailsOpened = bitSet.toString(); 79 | 80 | const saved = await Files.create(post, true); 81 | if (saved) { 82 | logDebug( 83 | logTags.Stats, 84 | `Batched tracking updated for post: ${post.title}.`, 85 | ); 86 | } 87 | } catch (error) { 88 | logError(logTags.Stats, error); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /views/partials/common/header.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 8 | 10 | 11 | 12 | 13 | 14 |
15 | Ghosler 17 | 18 | <% if (version) { %> 19 | (<%= version %>) 22 | <% } %> 23 |
24 |
25 |

Send newsletters to your Ghost subscribers!

26 |
27 | 28 | <% if (typeof showLogout === 'undefined') { 29 | showLogout = true; 30 | } %> 31 | 32 | <% if (showLogout) { %> 33 | 48 | <% } %> 49 |
50 |
51 | -------------------------------------------------------------------------------- /routes/settings.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import express from 'express'; 4 | import Ghost from '../utils/api/ghost.js'; 5 | import Files from '../utils/data/files.js'; 6 | import Transfers from '../utils/data/transfers.js'; 7 | import ProjectConfigs from '../utils/data/configs.js'; 8 | 9 | const router = express.Router(); 10 | 11 | router.get('/', async (_, res) => { 12 | const configs = await ProjectConfigs.all(); 13 | res.render('dashboard/settings', { configs: configs }); 14 | }); 15 | 16 | router.get('/template', async (_, res) => { 17 | const customTemplateExists = await Files.customTemplateExists(); 18 | res.render('dashboard/upload-template', { customTemplateExists }); 19 | }); 20 | 21 | router.get('/template/download/:type', async (req, res) => { 22 | const downloadType = req.params.type ?? 'base'; // default is base. 23 | 24 | let newsletterFilePath = path.join(process.cwd(), '/views/newsletter.ejs'); 25 | if (downloadType === 'custom_template') 26 | newsletterFilePath = Files.customTemplatePath(); 27 | 28 | const newsletterFile = fs.readFileSync(newsletterFilePath); 29 | res.setHeader('Content-Type', 'text/plain'); 30 | res.setHeader('Content-Disposition', 'attachment; filename=newsletter.ejs'); 31 | res.send(newsletterFile); 32 | }); 33 | 34 | router.post('/template', async (req, res) => { 35 | const customTemplateFile = req.files['custom_template.file']; 36 | const { level, message } = 37 | await ProjectConfigs.updateCustomTemplate(customTemplateFile); 38 | const customTemplateExists = await Files.customTemplateExists(); 39 | res.render('dashboard/upload-template', { 40 | level, 41 | message, 42 | customTemplateExists, 43 | }); 44 | }); 45 | 46 | router.post('/', async (req, res) => { 47 | const formData = req.body; 48 | 49 | let fullUrl = new URL( 50 | `${req.protocol}://${req.get('Host')}${req.originalUrl}`, 51 | ); 52 | 53 | if (req.get('Referer')) { 54 | fullUrl = new URL(req.get('Referer')); 55 | } 56 | 57 | fullUrl.search = ''; // drop query params 58 | fullUrl.pathname = fullUrl.pathname.split('/settings')[0]; 59 | fullUrl.pathname = fullUrl.pathname.replace(/\/+/g, '/'); 60 | 61 | // drop a trailing slash 62 | formData['ghosler.url'] = fullUrl.href.replace(/\/$/, '').toString(); 63 | 64 | const result = await ProjectConfigs.update(formData); 65 | const configs = await ProjectConfigs.all(); 66 | 67 | let { level, message } = result; 68 | 69 | if (configs.ghost.url && configs.ghost.key) { 70 | const ghost = new Ghost(); 71 | const tagResponse = await ghost.registerIgnoreTag(); 72 | const webhookResponse = await ghost.registerWebhook(); 73 | 74 | if (tagResponse.level === 'error') { 75 | level = tagResponse.level; 76 | message = tagResponse.message; 77 | } else if (webhookResponse.level === 'error') { 78 | level = webhookResponse.level; 79 | message = webhookResponse.message; 80 | } 81 | } 82 | 83 | res.render('dashboard/settings', { level, message, configs }); 84 | }); 85 | 86 | router.get('/configs/export', async (_, res) => await Transfers.export(res)); 87 | 88 | router.get('/configs', async (_, res) => res.render('dashboard/import-export')); 89 | 90 | router.post('/configs', async (req, res) => { 91 | const { level, message } = await Transfers.import(req); 92 | 93 | res.render('dashboard/import-export', { 94 | level, 95 | message, 96 | invalidate_session: level === 'success', 97 | }); 98 | }); 99 | 100 | export default router; 101 | -------------------------------------------------------------------------------- /routes/newsletters.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import Ghost from '../utils/api/ghost.js'; 3 | import Post from '../utils/models/post.js'; 4 | import Files from '../utils/data/files.js'; 5 | import Newsletter from '../utils/newsletter.js'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/', (req, res) => res.redirect('/')); 10 | 11 | router.get('/:postId', async (req, res) => { 12 | const postId = req.params.postId; 13 | 14 | const postObject = await Files.get(postId); 15 | if (!postObject) { 16 | return res.render('dashboard/newsletters', { 17 | level: 'error', 18 | message: 'Invalid Post Id!', 19 | }); 20 | } 21 | 22 | if ( 23 | postObject && 24 | postObject.stats && 25 | postObject.stats.newsletterStatus === 'Unsent' 26 | ) { 27 | const newsletterItems = await new Ghost().newsletters(); 28 | delete newsletterItems.meta; // we don't need meta here. 29 | 30 | // add the Generic Newsletter item as first. 31 | const newsletters = [Ghost.genericNewsletterItem, ...newsletterItems]; 32 | 33 | res.render('dashboard/newsletters', { 34 | post: postObject, 35 | newsletters: newsletters, 36 | }); 37 | } else { 38 | res.render('dashboard/newsletters', { 39 | level: 'error', 40 | message: 'This post is already sent as a newsletter via email.', 41 | }); 42 | } 43 | }); 44 | 45 | router.post('/send', async (req, res) => { 46 | const formData = req.body; 47 | const postId = formData.postId; 48 | const newsletterId = formData.newsletterId; 49 | const newsletterName = formData.newsletterName; 50 | 51 | if (!postId || !newsletterId || !newsletterName) { 52 | return res.render('dashboard/newsletters', { 53 | level: 'error', 54 | message: 'Post Id, Newsletter Id or Newsletter Name is missing!', 55 | }); 56 | } 57 | 58 | const postObject = await Files.get(postId); 59 | if (!postObject) { 60 | return res.render('dashboard/newsletters', { 61 | level: 'error', 62 | message: 'Invalid Post Id!', 63 | }); 64 | } 65 | 66 | const post = Post.fromRaw(postObject); 67 | 68 | /** 69 | * If `post.content` is not available or empty, 70 | * then an 'empty email' is sent to the members which isn't right. 71 | * 72 | * Therefore, we ensure below checks - 73 | * 74 | * 1. A valid Post is available 75 | * 2. Post contains some content 76 | * 3. Post contains stats object & 77 | * 4. Post's Stats newsletter status is 'Unsent'. 78 | */ 79 | if ( 80 | post && 81 | post.content && 82 | post.stats && 83 | post.stats.newsletterStatus === 'Unsent' 84 | ) { 85 | /** 86 | * Mark the post's current status as 'Sending' 87 | * This is done to prevent re-sending until Ghosler fetches members. 88 | */ 89 | post.stats.newsletterStatus = 'Sending'; 90 | post.stats.newsletterName = newsletterName; 91 | await post.update(true); 92 | 93 | // send the newsletter as usual. 94 | Newsletter.send(post, { 95 | id: newsletterId, 96 | name: newsletterName, 97 | }).then(); 98 | 99 | res.render('dashboard/newsletters', { 100 | post: post, 101 | level: 'success', 102 | message: 'Newsletter will be sent shortly.', 103 | }); 104 | } else { 105 | let message = 'This post is already sent as a newsletter via email.'; 106 | if (!post || !post.content || !post.stats) 107 | message = 'Post does not seem to be valid.'; 108 | 109 | res.render('dashboard/newsletters', { 110 | level: 'error', 111 | message: message, 112 | }); 113 | } 114 | }); 115 | 116 | export default router; 117 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('./partials/common/header.ejs') %> 12 | 13 |
14 | 15 | <%- include('./partials/common/message.ejs') %> 16 | 17 | 18 |
19 | 20 |
21 |
22 |

Manage Your Settings

23 |

Adjust your overall & newsletter settings for optimal experience.

24 |
25 | Settings 27 |
28 | 29 | 30 |
31 |
32 |

Analytics Dashboard

33 |

Explore comprehensive analytics for your newsletter.

34 |
35 | Analytics 37 |
38 | 39 | 40 |
41 |
42 |

Debug Logs

43 |

Check debug level logs for debugging or finding certain info.

44 |
45 | View 47 | Logs 48 |
49 | 50 | 51 |
52 |
53 |

Error Logs

54 |

Check error level logs to see if something went wrong.

55 |
56 | View 58 | Logs 59 |
60 | 61 | 62 |
63 |
64 |

Import/Export

65 |

Easily manage your configurations & analytics.

66 |
67 | 68 | Open Dashboard 70 |
71 | 72 | 73 |
74 |
75 |

Found an Issue?

76 |

Drop a message to Ghosler's Issue Tracker on GitHub.

77 |
78 | Report 80 |
81 |
82 | 83 |
84 | 85 | <%- include('./partials/common/footer.ejs') %> 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /views/dashboard/newsletters.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Newsletters - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | 21 | 22 | <%- include('../partials/common/message.ejs') %> 23 | 24 | <% if (typeof newsletters === 'undefined' || newsletters.length === 0) { %> 25 | <% if (typeof message === 'undefined') { %> 26 |

No Newsletters found!

27 | <% } %> 28 | <% } else { %> 29 | 30 |

31 | Select a newsletter for Post - <%= post.title; %> 32 |

33 | 34 | 35 | 68 | 69 | 70 |
71 | <% newsletters.forEach(element => { %> 72 |
73 |
74 |
<%= element.name; %>
75 |
<%= element.description %>
76 |
77 | 78 |
79 |
80 |
81 | 82 | 83 | 84 | 85 | 90 |
91 |
92 |
93 |
94 | <% }); %> 95 |
96 | <% } %> 97 |
98 | 99 | <%- include('../partials/common/footer.ejs') %> 100 | 101 | <% if (typeof level !== 'undefined') { %> 102 | 103 | <% } %> 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /utils/models/post.js: -------------------------------------------------------------------------------- 1 | import Stats from './stats.js'; 2 | import Files from '../data/files.js'; 3 | import Miscellaneous from '../misc.js'; 4 | 5 | /** 6 | * Represents a Post. 7 | */ 8 | export default class Post { 9 | /** 10 | * Creates an instance of Post. 11 | * 12 | * @param {string} [id=''] - The unique identifier for the Post. 13 | * @param {string} [url=''] - The URL of the Post. 14 | * @param {string} [date=''] - The date of the Post. 15 | * @param {string} [title=''] - The title of the Post. 16 | * @param {string} [content=''] - The content of the Post in HTML format. 17 | * @param {string} [primaryTag=''] - The primary tag of the Post. 18 | * @param {string} [excerpt=''] - The short excerpt of the Post. 19 | * @param {string} [featureImage=''] - The URL of the feature image of the Post. 20 | * @param {string} [featureImageCaption=''] - The caption of the feature image. 21 | * @param {string} [primaryAuthor=''] - The primary author of the Post. 22 | * @param {string} [visibility=''] - The visibility of the Post. 23 | * @param {Object[]} [tiers] - The tiers of the Post. 24 | * @param {Stats} [stats=new Stats()] - The statistics related to this Post. 25 | */ 26 | constructor( 27 | id = '', 28 | url = '', 29 | date = '', 30 | title = '', 31 | content = '', 32 | primaryTag = '', 33 | excerpt = '', 34 | featureImage = '', 35 | featureImageCaption = '', 36 | primaryAuthor = '', 37 | visibility = '', 38 | tiers = [], 39 | stats = new Stats(), 40 | ) { 41 | this.id = id; 42 | this.url = url; 43 | this.date = date; 44 | this.title = title; 45 | this.content = content; 46 | this.primaryTag = primaryTag; 47 | this.excerpt = excerpt; 48 | this.featureImage = featureImage; 49 | this.featureImageCaption = featureImageCaption; 50 | this.primaryAuthor = primaryAuthor; 51 | this.visibility = visibility; 52 | this.tiers = tiers; 53 | this.stats = stats; 54 | } 55 | 56 | /** 57 | * Check if this is a paying members content or not. 58 | * 59 | * @returns {boolean} True if paid post, false otherwise. 60 | */ 61 | get isPaid() { 62 | return this.visibility === 'paid' || this.visibility === 'tiers'; 63 | } 64 | 65 | /** 66 | * Make a Post object from the received payload. 67 | * 68 | * @param {Object} payload - The payload to create a Post from. 69 | * @returns {Post} The newly created Post object. 70 | */ 71 | static make(payload) { 72 | const post = payload.post.current; 73 | 74 | return new Post( 75 | post.id, 76 | post.url, 77 | Miscellaneous.formatDate(post.published_at), 78 | post.title, 79 | post.html, 80 | post.primary_tag?.name ?? '', 81 | post.custom_excerpt ?? 82 | post.excerpt ?? 83 | post.plaintext?.substring(0, 75) ?? 84 | post.title + '...', 85 | post.feature_image, 86 | post.feature_image_caption, 87 | post.primary_author.name, 88 | post.visibility, 89 | post.tiers, 90 | new Stats(), 91 | ); 92 | } 93 | 94 | /** 95 | * Convert the saved post object to class. 96 | * 97 | * @param {Object} object - The saved Post object on disk. 98 | * @returns {Post} The newly created Post object. 99 | */ 100 | static fromRaw(object) { 101 | const instance = new Post(); 102 | Object.assign(instance, object); 103 | return instance; 104 | } 105 | 106 | /** 107 | * Check if we should ignore the post based on the tags it contains. 108 | * 109 | * @param payload - The payload to check against. 110 | * @returns {boolean} True if the internal tag is found, false otherwise. 111 | */ 112 | static containsIgnoreTag(payload) { 113 | // it is always an array. 114 | const postTags = payload.post.current.tags; 115 | return postTags.some((tag) => tag.slug === 'ghosler_ignore'); 116 | } 117 | 118 | /** 119 | * Save the data when first received from the webhook. 120 | * 121 | * @param {boolean} complete Whether to save the post object completely. 122 | * @returns {Promise} A promise that resolves to true if file creation succeeded, false otherwise. 123 | */ 124 | async save(complete = false) { 125 | let contentToSave = complete ? this : this.#saveable(); 126 | 127 | if (complete) { 128 | // explicitly use 'Unsent' else the 129 | // 'Send' button won't show up in dashboard. 130 | contentToSave.stats.newsletterStatus = 'Unsent'; 131 | 132 | // persisted data stores 'author' key. 133 | contentToSave.author = contentToSave.primaryAuthor; 134 | } 135 | 136 | return await Files.create(contentToSave); 137 | } 138 | 139 | /** 140 | * Saves the updated data. 141 | * 142 | * @param {boolean} complete Whether to save the post object completely. 143 | * @returns {Promise} A promise that resolves to true if file creation succeeded, false otherwise. 144 | */ 145 | async update(complete = false) { 146 | const contentToSave = complete ? this : this.#saveable(); 147 | if (complete) { 148 | // persisted data stores 'author' key. 149 | contentToSave.author = contentToSave.primaryAuthor; 150 | } 151 | 152 | return await Files.create(contentToSave, true); 153 | } 154 | 155 | /** 156 | * Returns only the fields that need to be saved. 157 | * 158 | * @returns {Object} Fields that need to be saved. 159 | */ 160 | #saveable() { 161 | return { 162 | id: this.id, 163 | url: this.url, 164 | date: this.date, 165 | title: this.title, 166 | author: this.primaryAuthor, 167 | stats: this.stats, 168 | }; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Ghosler - Ghost Newsletters 👻 2 | 3 | **Ghosler** enables easy sending of newsletters using your own email and SMTP credentials.\ 4 | This is ideal when you are just starting out and have a small to moderate user base. 5 | 6 | It is helpful for bypassing the limitations of the hardcoded Mailgun setup and supports many analytical features, along 7 | with the capability to use **multiple** email accounts. 8 | 9 | --- 10 | 11 | ### Table of Contents 12 | 13 | - [Screenshots](#screenshots) 14 | - [Key Features](#key-features) 15 | - [Running Ghosler](#running-ghosler) 16 | - [Setup Instructions](#setup-instructions) 17 | - [Testing Configurations](#testing-configurations) 18 | - [Using Custom Templates](#custom-template) 19 | 20 | --- 21 | 22 | ### Screenshots 23 | 24 | - **Newsletter** 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
Ghosler - Newsletter PreviewGhosler - Newsletter PreviewGhosler - Newsletter PreviewGhosler - Newsletter Preview
33 | 34 | ### Key Features 35 | 36 | - **Support for Multiple Email Accounts**: Distribute newsletters through various email accounts, dividing member counts 37 | accordingly. 38 | - **Tracking Email Performance**: Monitor email deliverability and open rates. 39 | - **Customize Newsletter**: You can customize the newsletter via Settings, or go full on by providing 40 | a [custom template](#custom-template). 41 | - **Paid & Free Members Management**: Ghosler shows a **Subscribe** button to members who do not have access to paid 42 | content. 43 | - **URL Click Tracking in Emails**: Ghosler supports tracking URL clicks in the email, providing more insights on how 44 | your members are interacting with added links. 45 | - **Newsletter Post Feedback**: Gain insights into reader preferences with detailed feedback on each post, including 46 | likes, dislikes, and overall sentiment. 47 | - **Ghost Native Widgets**: Ghosler supports major Ghost widgets (cards) for your newsletter, out of the box. 48 | - **Custom Email Subjects**: Ghosler allows using customised email subject for your newsletter. 49 | - **Multiple Newsletters**: Ghosler supports managing multiple newsletters! Publish a post & select the newsletter to 50 | associate with & instantly send emails. 51 | - **Docker Support**: Ghosler also supports a straight forward Docker container. 52 | 53 | ### Running Ghosler 54 | 55 | #### 1. Installation with `NPM` 56 | 57 | Pre-requisites: `Node 18^` & `pm2` installed. 58 | 59 | 1. Install the `CLI` - 60 | 61 | ```npm 62 | npm install -g ghosler-cli 63 | ``` 64 | 65 | 2. Go to the directory you want to install `Ghosler`, make sure its empty & run below command - 66 | 67 | ```shell 68 | ghosler install 69 | ``` 70 | 71 | #### 2. Installation with `Docker` 72 | 73 | Execute this script to install or update `Ghosler` via `Docker` - 74 | 75 | ```bash 76 | curl -sSL https://raw.githubusercontent.com/ItzNotABug/ghosler/master/docker-install.sh -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh 77 | ``` 78 | 79 | If you already have `docker-install.sh` on your system, simply execute - 80 | 81 | ```bash 82 | ./docker-install.sh 83 | ``` 84 | 85 | Now navigate to main site & edit required settings after completing the Setup instructions below. 86 | 87 | ### Setup Instructions 88 | 89 | 1. **Access Ghost Dashboard**: Navigate to your Ghost dashboard. 90 | 2. **Create Custom Integration**: 91 | - Go to **Settings** > **Advanced** > **Integrations**. 92 | - Click on **Add Custom Integration**. 93 | - Name the integration (e.g., Newsletters) and click **Add**. 94 | - **Copy** the **Admin API Key** displayed. 95 | 3. **Configure Ghosler**: 96 | - Fire up the Ghosler front-end by going to `https://your-domain.com`. 97 | - Default `PORT` is `2369` 98 | - Default login credentials are - Username: `ghosler`, Password - `admin` 99 | - Click on **Settings** button. 100 | - Click on **Ghost Settings** & add your **Ghost Site Url** & **Admin API Key**. 101 | - Add mail configurations in **Emails** section. 102 | - Change other settings you wish to and click **Save Changes**. 103 | Upon clicking **Save Changes**, Ghosler will automatically create a new `Webhook` in the Ghost Integration (if it 104 | doesn't already exist). 105 | This webhook enables **Ghosler** to receive information about posts when they are published. 106 | 4. **Only publishing a Post**: If you want to only publish a post & not send it via email, just add the `#GhoslerIgnore` 107 | tag to a post. The internal tag is created for you on the initial setup. 108 | 109 | Now as soon as you publish your Post, it will be sent to your Subscribers who have enabled receiving emails. 110 | 111 | ### Testing Configurations 112 | 113 | Ghosler defaults to using a local configuration file, `config.local.json`, if it exists. The structure of this file is 114 | identical to that in [config.production.json](./configuration/config.production.json) file. 115 | 116 | **Note: `config.local.json` should be placed inside the `configuration` directory.** 117 | 118 | **Local Builds:** 119 | Make sure to execute - 120 | 121 | ```shell 122 | npm run buildcss 123 | ``` 124 | 125 | to generate a minified css if you changed any `.ejs` files. 126 | If you don't, CSS based changes won't take effect. This also makes sure that the final CSS bundle includes only what's 127 | needed. 128 | 129 | And use below to run `Ghosler` - 130 | 131 | ```shell 132 | npm run dev 133 | ``` 134 | 135 | You can use below for combining the above commands - 136 | 137 | ```shell 138 | npm run cleanstart 139 | ``` 140 | 141 | ##### Building the Docker Image - 142 | 143 | ```bash 144 | docker build -t ghosler . --no-cache 145 | ``` 146 | 147 | After a successful local build, run the container - 148 | 149 | ```bash 150 | docker run --rm name ghosler -d -p 2369:2369 -v ghosler-logs:/usr/src/app/.logs -v ghosler-analytics:/usr/src/app/files -v ghosler-configuration:/usr/src/app/configuration ghosler 151 | ``` 152 | 153 | **Note**: For testing the Docker container over a publicly accessible URL, I used `Cloudflare Tunnel` as it doesn't have 154 | a startup page like `ngrok` or the `VSCode`'s dev tunnel and works good for testing the Ghost Webhooks. 155 | 156 | Assuming you have `TryCloudflare CLI` installed, you can do something like this - 157 | 158 | ```bash 159 | cloudflared tunnel --url http://localhost:2369 160 | ``` 161 | 162 | This command will initialize a tunnel and return a URL that you can use to test.\ 163 | For more info, 164 | see - [TryCloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/). 165 | 166 | ### Custom Template 167 | 168 | If you want to customize the newsletter template even more, follow the steps - 169 | 170 | 1. Create a **custom-template.ejs** 171 | 2. Customize it as you like, for reference - you can download the `Base Template` from settings itself 172 | 3. Upload it via dashboard & that's it! Ghosler will use the new template for preview & sending newsletter 173 | 4. Disable from settings if you don't want to use the custom template 174 | 175 | --- 176 | 177 | #### And don't forget to `⭐` the project! -------------------------------------------------------------------------------- /views/partials/settings/mail.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

Email Settings

5 | 7 | 8 | 9 |
10 | 11 | 110 |
-------------------------------------------------------------------------------- /utils/data/transfers.js: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import path from 'path'; 3 | import fs from 'fs/promises'; 4 | import archiver from 'archiver'; 5 | import extract from 'extract-zip'; 6 | import ProjectConfigs from './configs.js'; 7 | import { logError, logTags } from '../log/logger.js'; 8 | 9 | /** 10 | * A utility class to manage import and exports of the site configurations. 11 | */ 12 | export default class Transfers { 13 | /** 14 | * Export the site configurations for backup. 15 | * 16 | * @param {any} res - The response object to send the `zip` to client for download. 17 | */ 18 | static async export(res) { 19 | res.setHeader('Content-Type', 'application/zip'); 20 | res.setHeader( 21 | 'Content-Disposition', 22 | 'attachment; filename="ghosler-backup.zip"', 23 | ); 24 | 25 | const filesDir = path.join(process.cwd(), 'files'); 26 | const configsDir = path.join(process.cwd(), 'configuration'); 27 | 28 | const archive = archiver('zip', { zlib: { level: 9 } }); 29 | archive.pipe(res); 30 | 31 | archive.directory(filesDir, 'files'); 32 | archive.directory(configsDir, 'configuration'); 33 | 34 | await archive.finalize(); 35 | } 36 | 37 | /** 38 | * Import the site configurations from a provided backup. 39 | * 40 | * @param {any} req - The request object to get the `zip` to client for download. 41 | * @returns {Promise<{level: string, message: string}>} - True if import file was correctly restored, False if something went wrong. 42 | */ 43 | static async import(req) { 44 | const importZipFile = req.files['import.file']; 45 | if (!importZipFile) { 46 | return { 47 | level: 'error', 48 | message: 'No import file was found for restore!', 49 | }; 50 | } 51 | 52 | try { 53 | const tempDirectoryPath = path.join( 54 | os.tmpdir(), 55 | `import_${Date.now()}`, 56 | ); 57 | await fs.mkdir(tempDirectoryPath, { recursive: true }); 58 | 59 | const tempZipPath = path.join(tempDirectoryPath, 'import.zip'); 60 | await importZipFile.mv(tempZipPath); 61 | 62 | await extract(tempZipPath, { dir: tempDirectoryPath }); 63 | 64 | const filesDir = path.join(process.cwd(), 'files'); 65 | const configsDir = path.join(process.cwd(), 'configuration'); 66 | 67 | const tempFilesDir = path.join(tempDirectoryPath, 'files'); 68 | const tempConfigsDir = path.join( 69 | tempDirectoryPath, 70 | 'configuration', 71 | ); 72 | 73 | // direct copy the analytics. 74 | await this.#copy(tempFilesDir, filesDir); 75 | 76 | // we need to check and verify the config file for correct schema! 77 | const isSchemaVerified = 78 | await this.#verifyConfigSchema(tempConfigsDir); 79 | if (!isSchemaVerified) { 80 | return { 81 | level: 'error', 82 | message: 'Configuration schema does not match!', 83 | }; 84 | } 85 | 86 | await this.#copy(tempConfigsDir, configsDir); 87 | 88 | try { 89 | // clean up temp dir. 90 | await fs.rm(tempDirectoryPath); 91 | } catch (ignore) { 92 | // ignore this. 93 | } 94 | 95 | // force update the configs! 96 | await ProjectConfigs.reset(); 97 | 98 | return { 99 | level: 'success', 100 | message: 'Import successful! You will now be logged out.', 101 | }; 102 | } catch (error) { 103 | logError(logTags.Configs, error); 104 | return { 105 | level: 'error', 106 | message: 107 | 'Something went wrong while importing the configurations. Check error logs for more info.', 108 | }; 109 | } 110 | } 111 | 112 | /** 113 | * Copy the file from source to a destination. 114 | * 115 | * @param {string} source - The directory source to copy files from. 116 | * @param {string} destination - The directory source to copy files to. 117 | */ 118 | static async #copy(source, destination) { 119 | try { 120 | if (!(await fs.stat(source).catch(() => false))) return; 121 | 122 | const files = await fs.readdir(source); 123 | const copyPromises = files.map(async (file) => { 124 | const sourcePath = path.join(source, file); 125 | const destPath = path.join(destination, file); 126 | 127 | /// override the files if they exist! 128 | await fs.copyFile(sourcePath, destPath); 129 | }); 130 | 131 | await Promise.all(copyPromises); 132 | } catch (error) { 133 | logError(logTags.Configs, `Error copying files: ${error}`); 134 | } 135 | } 136 | 137 | /** 138 | * Check if the config file has all the required schema keys. 139 | * 140 | * @param {string} source - The source directory where the config file exists. 141 | * @returns {Promise} - True if all keys exists, False otherwise. 142 | */ 143 | static async #verifyConfigSchema(source) { 144 | let fileName = 'config.production.json'; 145 | const files = await fs.readdir(source); 146 | if (files.some((file) => file === 'config.local.json')) { 147 | fileName = 'config.local.json'; 148 | } 149 | 150 | const configFile = path.join(source, fileName); 151 | const configFileContent = await fs.readFile(configFile, 'utf8'); 152 | const configFileAsJson = JSON.parse(configFileContent); 153 | 154 | // compare the keys 155 | const keysToCompare = [ 156 | 'ghosler', 157 | 'ghosler.url', 158 | 'ghosler.port', 159 | 'ghosler.auth', 160 | 'ghosler.auth.user', 161 | 'ghosler.auth.pass', 162 | 163 | 'ghost', 164 | 'ghost.url', 165 | 'ghost.key', 166 | 'ghost.secret', 167 | 'ghost.version', 168 | 169 | 'newsletter', 170 | 'newsletter.track_links', 171 | 'newsletter.center_title', 172 | 'newsletter.show_excerpt', 173 | 'newsletter.footer_content', 174 | 'newsletter.show_comments', 175 | 'newsletter.show_feedback', 176 | 'newsletter.show_subscription', 177 | 'newsletter.show_featured_image', 178 | 'newsletter.show_powered_by_ghost', 179 | 'newsletter.custom_subject_pattern', 180 | 'newsletter.show_powered_by_ghosler', 181 | 182 | 'custom_template', 183 | 'custom_template.enabled', 184 | 185 | 'mail', 186 | ]; 187 | 188 | const comparisonSuccess = keysToCompare.every((key) => { 189 | let current = configFileAsJson; 190 | const keyParts = key.split('.'); 191 | 192 | for (const part of keyParts) { 193 | if (current[part] === undefined) return false; 194 | current = current[part]; 195 | } 196 | 197 | return true; 198 | }); 199 | 200 | if (comparisonSuccess) { 201 | // if the backup is restored on a different domain, 202 | // the css and other resources [if any], will fail to load. 203 | // Using empty url will use a relative url which works everywhere. 204 | configFileAsJson.ghosler.url = ''; 205 | 206 | try { 207 | await fs.writeFile( 208 | path.join(source, fileName), 209 | JSON.stringify(configFileAsJson), 210 | ); 211 | } catch (error) { 212 | // this is not good, but not fatal too! 213 | // user might see a broken site but can probably be fixed after saving the settings. 214 | logError( 215 | logTags.Configs, 216 | 'Error updating the url in the backup file. Save settings again after a successful restore to fix this.', 217 | ); 218 | } 219 | } 220 | 221 | return comparisonSuccess; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /views/dashboard/details.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Details - Ghosler 5 | 6 | 7 | 28 | 29 | 30 | 31 | 32 | <%- include('../partials/common/header.ejs') %> 33 | 34 |
35 | 44 | 45 | 46 |
47 | <% if (postSentiments.positive_feedback === 0 && postSentiments.negative_feedback === 0) { %> 48 |

No feedback available for 49 | "<%= post ? post.title : 'NA'; %>" yet.

50 | <% } else { %> 51 | 52 |

Feedback for 53 | "<%= post ? post.title : 'NA'; %>"

54 | 55 |
56 | 57 | <% if (postSentiments.positive_feedback === 0 && postSentiments.negative_feedback === 0) { %> 58 | 59 | <% } else { %> 60 |
61 | 62 |
63 | <% } %> 64 | 65 | 66 |
67 |
68 |

Sentiment

69 |

<%= postSentiments.sentiment ?? 'N/A'; %> 70 | %

71 |
72 | 73 | 74 | 85 |
86 |
87 | <% } %> 88 |
89 | 90 | 91 | <% if (typeof post === 'undefined' || typeof post.stats === 'undefined' || !post.stats || !post.stats.postContentTrackedLinks || post.stats.postContentTrackedLinks.length === 0) { %> 92 |

No tracked URLs available for this post.

93 | <% } else { %> 94 |

URLs are tracked and incremented
every time they are 95 | clicked.

96 | 97 | <% post.stats.postContentTrackedLinks.sort((a, b) => Object.values(b)[0] - Object.values(a)[0]); %> 98 | 99 | 100 | 122 | 123 | 124 |
125 | <% post.stats.postContentTrackedLinks.forEach(element => { %> 126 |
127 | <% const link = Object.keys(element)[0]; %> 128 | <% const clicks = element[link]; %> 129 | <%= link.split(/[?#]/)[0]; %> 131 |
132 |
133 | Clicks: 134 | <%= clicks; %> 135 |
136 |
137 | <% }); %> 138 |
139 | <% } %> 140 |
141 | 142 | <%- include('../partials/common/footer.ejs') %> 143 | 144 | 145 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /views/dashboard/settings.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Settings - Ghosler 5 | 6 | 7 | 116 | 117 | 118 | 119 | 120 | <%- include('../partials/common/header.ejs') %> 121 | 122 |
123 | 130 | 131 | <% if (!configs) { %> 132 |

Unable to load settings.

133 | <% } else { %> 134 | 135 | <%- include('../partials/common/message.ejs') %> 136 | 137 |
138 | 139 | 140 | <%- include('../partials/settings/profile.ejs') %> 141 | 142 | 143 | <%- include('../partials/settings/ghost.ejs') %> 144 | 145 | 146 | <%- include('../partials/settings/customise.ejs') %> 147 | 148 | 149 | <%- include('../partials/settings/template.ejs') %> 150 | 151 | 152 | <%- include('../partials/settings/mail.ejs') %> 153 | 154 |
155 | 159 |
160 |
161 | <% } %> 162 | 163 |
164 | 165 | <%- include('../partials/common/footer.ejs') %> 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /utils/data/files.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import BitSet from '../bitset.js'; 4 | import { logDebug, logError, logTags } from '../log/logger.js'; 5 | 6 | /** 7 | * This class manages the analytics for each post sent via email as a newsletter. 8 | */ 9 | export default class Files { 10 | /** 11 | * Get the files' directory. 12 | */ 13 | static #filesPath() { 14 | return path.join(process.cwd(), 'files'); 15 | } 16 | 17 | /** 18 | * Get the logs' directory. 19 | */ 20 | static #logsPath() { 21 | return path.join(process.cwd(), '.logs'); 22 | } 23 | 24 | /** 25 | * Get the custom template's path. 26 | */ 27 | static #customTemplatePath() { 28 | return path.join(process.cwd(), 'configuration/custom-template.ejs'); 29 | } 30 | 31 | /** 32 | * * Asynchronously creates a JSON file with a specified name in a predefined directory. 33 | * 34 | * @param {Object} post - The post data to be saved as a file. 35 | * @param {boolean} update - Update the file if true. 36 | * @returns {Promise} A promise that resolves to true when the file is successfully created, 37 | * or rejects with false if an error occurs or if it already exists. 38 | */ 39 | static async create(post, update = false) { 40 | // Define the directory and file path 41 | const filePath = path.join(this.#filesPath(), `${post.id}.json`); 42 | 43 | const isExists = await this.exists(post.id); 44 | if (!update && isExists) return false; 45 | 46 | try { 47 | // create files directory. 48 | await this.makeFilesDir(); 49 | 50 | // Initialize / Update the file with provided post object. 51 | await fs.writeFile(filePath, JSON.stringify(post), 'utf8'); 52 | 53 | return true; 54 | } catch (error) { 55 | logError(logTags.Files, error); 56 | return false; 57 | } 58 | } 59 | 60 | /** 61 | * Gets the post data for the given postId. 62 | * 63 | * @param {string} postId - The ID of the post. 64 | * @returns {Promise} The post data if found, otherwise undefined. 65 | */ 66 | static async get(postId) { 67 | const isExists = await this.exists(postId); 68 | if (!isExists) return undefined; 69 | else { 70 | const filePath = path.join(this.#filesPath(), `${postId}.json`); 71 | 72 | try { 73 | const data = await fs.readFile(filePath, 'utf8'); 74 | return JSON.parse(data); 75 | } catch (error) { 76 | logError(logTags.Files, error); 77 | return undefined; 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Checks if a file exists. 84 | * 85 | * @param {string} name - The name of the file to check. 86 | * @returns {Promise} A promise that resolves to true if the file exists, false otherwise. 87 | */ 88 | static async exists(name) { 89 | const filePath = path.join(process.cwd(), 'files', `${name}.json`); 90 | 91 | try { 92 | await fs.access(filePath); 93 | return true; 94 | } catch (error) { 95 | return false; 96 | } 97 | } 98 | 99 | /** 100 | * Deletes post data for a given postId. 101 | * 102 | * @param postId The id of the post to be deleted. 103 | * @returns {Promise} True if deletion succeeded, false otherwise. 104 | */ 105 | static async delete(postId) { 106 | const filePath = path.join(this.#filesPath(), `${postId}.json`); 107 | 108 | const isExists = await this.exists(postId); 109 | if (!isExists) return true; 110 | 111 | try { 112 | await fs.rm(filePath); 113 | logDebug(logTags.Files, `Post data for Id: ${postId} deleted.`); 114 | return true; 115 | } catch (error) { 116 | logError(logTags.Files, error); 117 | return false; 118 | } 119 | } 120 | 121 | /** 122 | * Get all the data from all files for analysis. 123 | */ 124 | static async all() { 125 | try { 126 | // Get all file names in the files directory 127 | const files = await fs.readdir(this.#filesPath()); 128 | 129 | let totalPosts = 0; 130 | let totalSent = 0; 131 | let totalOpens = 0; 132 | 133 | const analytics = await Promise.all( 134 | files.map(async (file) => { 135 | const filePath = path.join(this.#filesPath(), file); 136 | const data = JSON.parse( 137 | await fs.readFile(filePath, 'utf8'), 138 | ); 139 | 140 | const numbered = new BitSet( 141 | data.stats.emailsOpened ?? '', 142 | ).popCount(); 143 | 144 | totalPosts += 1; 145 | totalOpens += numbered; 146 | totalSent += data.stats.emailsSent 147 | ? data.stats.emailsSent 148 | : 0; 149 | 150 | data.stats.emailsOpened = numbered; 151 | 152 | return data; 153 | }), 154 | ); 155 | 156 | const overview = { 157 | posts: totalPosts, 158 | sent: totalSent, 159 | opens: totalOpens, 160 | }; 161 | 162 | return { overview, analytics }; 163 | } catch (error) { 164 | logError(logTags.Files, error); 165 | return { overview: { posts: 0, sent: 0, opens: 0 }, analytics: [] }; 166 | } 167 | } 168 | 169 | /** 170 | * Clear the logs for the log file. 171 | * 172 | * @param {string} type The type of log. Can be debug or error. 173 | * @returns {Promise} True if clear operation succeeded, false otherwise. 174 | */ 175 | static async clearLogs(type) { 176 | const filePath = path.join(this.#logsPath(), `${type}.log`); 177 | try { 178 | await fs.writeFile(filePath, ''); 179 | return true; 180 | } catch (error) { 181 | logError(logTags.Files, error); 182 | return false; 183 | } 184 | } 185 | 186 | /** 187 | * Get the logs from the log files. 188 | * 189 | * @param {string} type The type of log. Can be debug or error. 190 | * @returns {Promise} Contents of the log file, empty object if an error occurred. 191 | */ 192 | static async logs(type) { 193 | const filePath = path.join(this.#logsPath(), `${type}.log`); 194 | 195 | try { 196 | let logContent = await fs.readFile(filePath, 'utf8'); 197 | logContent = this.#reverseLogEntries(logContent); 198 | 199 | return { content: logContent, level: type }; 200 | } catch (error) { 201 | if (error.toString().includes('no such file or directory')) { 202 | // Check and create the directory if it doesn't exist 203 | const logDir = this.#logsPath(); 204 | await this.makeFilesDir(logDir); 205 | 206 | // Now that the directory is ensured to exist, write the file 207 | const newFilePath = path.join(logDir, `${type}.log`); 208 | await fs.writeFile(newFilePath, '', 'utf-8'); 209 | } else logError(logTags.Files, error); 210 | 211 | // empty anyway! 212 | return { content: '', level: type }; 213 | } 214 | } 215 | 216 | /** 217 | * Checks whether the user has supplied a custom template to be used for preview & emails. 218 | * 219 | * @returns {Promise} True if a file named 'custom-template.ejs' exists. 220 | */ 221 | static async customTemplateExists() { 222 | try { 223 | await fs.access(this.#customTemplatePath()); 224 | return true; 225 | } catch (error) { 226 | return false; 227 | } 228 | } 229 | 230 | /** 231 | * Returns the Path for the custom template. 232 | * 233 | * @returns {string} Path of the file. 234 | */ 235 | static customTemplatePath() { 236 | return this.#customTemplatePath(); 237 | } 238 | 239 | /** 240 | * Create a directory with given name if it doesn't exist. 241 | * 242 | * @param {string} directory Name of the directory. Default is 'files'. 243 | * @returns {Promise} 244 | */ 245 | static async makeFilesDir(directory = this.#filesPath()) { 246 | // Create if it doesn't exist. 247 | await fs.mkdir(directory, { recursive: true }); 248 | } 249 | 250 | /** 251 | * Reverses the order of log entries in a given log content string. 252 | * Assumes that each log entry starts with a timestamp in the format [YYYY-MM-DD HH:MM:SS UTC]. 253 | * 254 | * @param {string} logContent - The log content to be reversed. 255 | * @returns {string} The log content with the order of entries reversed. 256 | */ 257 | static #reverseLogEntries(logContent) { 258 | const entryPattern = /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC]/; 259 | const entries = []; 260 | let currentEntry = []; 261 | 262 | logContent.split('\n').forEach((line) => { 263 | const trimmedLine = line.trim(); 264 | if (entryPattern.test(trimmedLine)) { 265 | if (currentEntry.length) { 266 | entries.push(currentEntry.join('\n')); 267 | } 268 | currentEntry = []; 269 | } 270 | if (trimmedLine) { 271 | currentEntry.push(trimmedLine); 272 | } 273 | }); 274 | 275 | // Add the last entry 276 | if (currentEntry.length) { 277 | entries.push(currentEntry.join('\n')); 278 | } 279 | 280 | // Reverse and join the entries, no additional newlines are needed 281 | return entries.reverse().join('\n'); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /views/partials/settings/customise.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

Newsletter Settings

5 | 7 | 8 | 9 |
10 | 11 | 159 |
160 | 161 | 200 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Darshan Pandya (@ItzNotABug) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /utils/api/ghost.js: -------------------------------------------------------------------------------- 1 | import Miscellaneous from '../misc.js'; 2 | import GhostAdminAPI from '@tryghost/admin-api'; 3 | import ProjectConfigs from '../data/configs.js'; 4 | import Subscriber from '../models/subscriber.js'; 5 | import { logError, logTags, logToConsole } from '../log/logger.js'; 6 | 7 | /** 8 | * Class that handles api calls with Ghost's Admin APIs. 9 | */ 10 | export default class Ghost { 11 | /** 12 | * A hardcoded 'Generic' newsletter type. 13 | * 14 | * The `id` is an MD5 Hash of string value 'ghosler'. 15 | * 16 | * @type {{id: string, name: string, description: string}} 17 | */ 18 | static genericNewsletterItem = { 19 | id: '6de8d1d3d29a03060e1c4fa745e0eba7', 20 | name: 'Generic', 21 | description: 22 | 'This option sends the post to all users, irrespective of their subscribed newsletter.', 23 | }; 24 | 25 | /** 26 | * Returns the ghost site data. 27 | * 28 | * @returns {Promise} The ghost site data. 29 | */ 30 | async site() { 31 | const ghost = await this.#ghost(); 32 | return await ghost.site.read(); 33 | } 34 | 35 | /** 36 | * Returns the number of 'active' newsletters. 37 | * 38 | * @returns {Promise} The number of active newsletters. 39 | */ 40 | async newslettersCount() { 41 | const newsletters = await this.newsletters(); 42 | return newsletters.meta?.pagination?.total ?? 0; 43 | } 44 | 45 | /** 46 | * Returns all the 'active' newsletters. 47 | * 48 | * @returns {Promise>} An array of newsletter objects. 49 | */ 50 | async newsletters() { 51 | const ghost = await this.#ghost(); 52 | return await ghost.newsletters.browse({ 53 | limit: 'all', 54 | filter: 'status:active', 55 | fields: 'id,name,description', 56 | }); 57 | } 58 | 59 | /** 60 | * Returns sentiment and feedback counts for a specific post. 61 | * 62 | * @param {string} postId - The ID of the post. 63 | * @returns {Promise<{ sentiment: number, negative_feedback: number, positive_feedback: number }>} - An object containing sentiment data and feedback counts. 64 | */ 65 | async postSentiments(postId) { 66 | const ghost = await this.#ghost(); 67 | try { 68 | const postInfo = await ghost.posts.read({ 69 | id: postId, 70 | include: 71 | 'sentiment,count.positive_feedback,count.negative_feedback', 72 | }); 73 | 74 | const { count, sentiment } = postInfo; 75 | const { negative_feedback, positive_feedback } = count; 76 | 77 | return { 78 | sentiment, 79 | negative_feedback, 80 | positive_feedback, 81 | }; 82 | } catch (error) { 83 | if (error.name !== 'NotFoundError') logError(logTags.Ghost, error); 84 | else { 85 | // ignore, the post is probably deleted or the provided ID is wrong. 86 | } 87 | 88 | return { 89 | sentiment: 0, 90 | negative_feedback: 0, 91 | positive_feedback: 0, 92 | }; 93 | } 94 | } 95 | 96 | /** 97 | * Returns the registered members, currently subscribed to a newsletter.\ 98 | * Uses pagination to get all the users, then filter them. 99 | * 100 | * @param {string|null} newsletterId The newsletter id to get members associated with it. 101 | * @returns {Promise} List of Subscribers. 102 | */ 103 | async members(newsletterId = null) { 104 | let page = 1; 105 | let subscribedMembers = []; 106 | 107 | const ghost = await this.#ghost(); 108 | 109 | while (true) { 110 | const registeredMembers = await ghost.members.browse({ 111 | page: page, 112 | filter: 'subscribed:true', 113 | }); 114 | 115 | subscribedMembers.push(...registeredMembers); 116 | 117 | if (registeredMembers.meta.pagination.next) { 118 | page = registeredMembers.meta.pagination.next; 119 | } else { 120 | break; 121 | } 122 | } 123 | 124 | return subscribedMembers.reduce((activeSubscribers, member) => { 125 | const subscriber = Subscriber.make(member); 126 | if (subscriber.isSubscribedTo(newsletterId)) 127 | activeSubscribers.push(subscriber); 128 | return activeSubscribers; 129 | }, []); 130 | } 131 | 132 | /** 133 | * Check if the site has enabled comments. 134 | * 135 | * Returns true until this is supported by the api. 136 | * 137 | * @returns {Promise} 138 | */ 139 | async hasCommentsEnabled() { 140 | try { 141 | const settings = await this.#settings(); 142 | 143 | // supposed to be an array of objects but lets check anyway! 144 | if (Array.isArray(settings)) { 145 | const commentsSettingsKey = 'comments_enabled'; 146 | const settingObject = settings.filter( 147 | (obj) => obj.key === commentsSettingsKey, 148 | )[0]; 149 | const commentsEnabled = settingObject 150 | ? settingObject.value !== 'off' 151 | : true; 152 | logToConsole( 153 | logTags.Ghost, 154 | `Site comments enabled: ${commentsEnabled}`, 155 | ); 156 | return commentsEnabled; 157 | } else { 158 | // no idea about this unknown structure, return a default value! 159 | logToConsole( 160 | logTags.Ghost, 161 | 'Could not check if the site has comments enabled, defaulting to true', 162 | ); 163 | return true; 164 | } 165 | } catch (error) { 166 | logError(logTags.Ghost, error); 167 | return true; 168 | } 169 | } 170 | 171 | /** 172 | * Returns a list of latest post. 173 | * 174 | * @param {string} currentPostId Current post id. 175 | * @returns {Promise<[Object]>} 176 | */ 177 | async latest(currentPostId) { 178 | const ghost = await this.#ghost(); 179 | return await ghost.posts.browse({ 180 | filter: `status:published+id:-${currentPostId}`, 181 | order: 'published_at DESC', 182 | limit: 3, 183 | fields: 'title, custom_excerpt, excerpt, url, feature_image', 184 | }); 185 | } 186 | 187 | /** 188 | * Registers a webhook to receive post data on publish if one doesn't exist. 189 | * 190 | * **Note:** Ignored when running on localhost. 191 | * 192 | * @returns {Promise<{level: string, message: string}>} 193 | */ 194 | async registerWebhook() { 195 | const ghosler = await ProjectConfigs.ghosler(); 196 | if (ghosler.url === '' || ghosler.url.includes('localhost')) { 197 | return { level: 'warning', message: 'Ignore webhook check.' }; 198 | } 199 | 200 | const ghost = await this.#ghost(); 201 | const secret = (await ProjectConfigs.ghost()).secret; 202 | if (!secret || secret === '') { 203 | return { 204 | level: 'error', 205 | message: 206 | 'Secret is not set or empty or is less than 8 characters.', 207 | }; 208 | } 209 | 210 | try { 211 | await ghost.webhooks.add({ 212 | name: 'Ghosler Webhook', 213 | event: 'post.published', 214 | target_url: `${ghosler.url}/published`, 215 | secret: secret, 216 | }); 217 | 218 | return { 219 | level: 'success', 220 | message: 'Webhook created successfully.', 221 | }; 222 | } catch (error) { 223 | const context = error.context; 224 | if (error.name === 'UnauthorizedError') { 225 | return { 226 | level: 'error', 227 | message: 228 | 'Unable to check for Webhook, Ghost Admin API not valid.', 229 | }; 230 | } else if ( 231 | context === 'Target URL has already been used for this event.' 232 | ) { 233 | return { 234 | level: 'success', 235 | message: 'Webhook exists for this API Key.', 236 | }; 237 | } else { 238 | logError(logTags.Ghost, error); 239 | return { 240 | level: 'error', 241 | message: 'Webhook creation failed, see error logs.', 242 | }; 243 | } 244 | } 245 | } 246 | 247 | /** 248 | * Add a private, internal tag to ignore a post for newsletter. 249 | * 250 | * **Note:** Ignored when running on localhost. 251 | * 252 | * @returns {Promise<{level: string, message: string}>} 253 | */ 254 | async registerIgnoreTag() { 255 | const ghosler = await ProjectConfigs.ghosler(); 256 | if (ghosler.url === '' || ghosler.url.includes('localhost')) { 257 | return { level: 'warning', message: 'Ignore tag check.' }; 258 | } 259 | 260 | try { 261 | const ghost = await this.#ghost(); 262 | const ignoreTagSlug = 'ghosler_ignore'; 263 | 264 | // check if one already exists with given slug. 265 | const exists = await this.#ignoreTagExists(ghost, ignoreTagSlug); 266 | if (exists) 267 | return { 268 | level: 'success', 269 | message: 'Ghosler ignore tag already exists.', 270 | }; 271 | 272 | await ghost.tags.add({ 273 | slug: ignoreTagSlug, 274 | name: '#GhoslerIgnore', 275 | visibility: 'internal', // using # anyway makes it internal. 276 | accent_color: '#0f0f0f', 277 | description: 278 | 'Any post using this tag will be ignore by Ghosler & will not be sent as a newsletter email.', 279 | }); 280 | 281 | return { 282 | level: 'success', 283 | message: 'Ignore tag created successfully.', 284 | }; 285 | } catch (error) { 286 | logError(logTags.Ghost, error); 287 | return { 288 | level: 'error', 289 | message: 'Ignore tag creation failed, see error logs.', 290 | }; 291 | } 292 | } 293 | 294 | /** 295 | * Check if a given exists or not. 296 | * 297 | * @param {GhostAdminAPI} ghost 298 | * @param {string} tagSlug 299 | * 300 | * @returns {Promise} 301 | */ 302 | async #ignoreTagExists(ghost, tagSlug) { 303 | try { 304 | await ghost.tags.read({ slug: tagSlug }); 305 | return true; 306 | } catch (error) { 307 | return false; 308 | } 309 | } 310 | 311 | /** 312 | * Read the site's settings. We especially check if the site has comments enabled. 313 | * 314 | * **Be Careful: This api is not officially baked into GhostAdminAPI & is added manually! 315 | * This can change anytime! See: https://github.com/TryGhost/Ghost/issues/19271**. 316 | * 317 | * @returns {Promise} 318 | */ 319 | async #settings() { 320 | const ghost = await ProjectConfigs.ghost(); 321 | let token = `Ghost ${Miscellaneous.ghostToken(ghost.key, '/admin/')}`; 322 | const ghostHeaders = { 323 | Authorization: token, 324 | 'User-Agent': 'GhostAdminSDK/1.13.11', 325 | }; 326 | 327 | const response = await fetch(`${ghost.url}/ghost/api/admin/settings`, { 328 | headers: ghostHeaders, 329 | }); 330 | if (!response.ok) { 331 | // will be caught by the calling function anyway. 332 | throw new Error(`HTTP error! status: ${response.status}`); 333 | } 334 | 335 | const jsonResponse = await response.json(); 336 | return jsonResponse.settings; 337 | } 338 | 339 | /** @returns {Promise} */ 340 | async #ghost() { 341 | const ghost = await ProjectConfigs.ghost(); 342 | return new GhostAdminAPI({ 343 | url: ghost.url, 344 | key: ghost.key, 345 | version: ghost.version, 346 | }); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /utils/mail/mailer.js: -------------------------------------------------------------------------------- 1 | import Miscellaneous from '../misc.js'; 2 | import * as nodemailer from 'nodemailer'; 3 | import ProjectConfigs from '../data/configs.js'; 4 | import { logDebug, logError, logTags, logToConsole } from '../log/logger.js'; 5 | 6 | /** 7 | * Class responsible for sending newsletters to subscribers. 8 | */ 9 | export default class NewsletterMailer { 10 | /** 11 | * Creates an instance of NewsletterMailer. 12 | * 13 | * @param {Post} post - The post to be sent. 14 | * @param {Subscriber[]} subscribers - An array of subscribers. 15 | * @param {string|null} newsletterName - The name of the newsletter. 16 | * @param {string} fullContent - The HTML content of the email. 17 | * @param {string|undefined} partialContent - Partial HTML content of the email for non-paying users. 18 | * @param {string} unsubscribeLink - An unsubscribe link for the subscribers. 19 | */ 20 | constructor( 21 | post, 22 | subscribers, 23 | newsletterName, 24 | fullContent, 25 | partialContent, 26 | unsubscribeLink, 27 | ) { 28 | this.post = post; 29 | this.subscribers = subscribers; 30 | this.newsletterName = newsletterName; 31 | this.fullContent = fullContent; 32 | this.partialContent = partialContent; 33 | this.unsubscribeLink = unsubscribeLink; 34 | } 35 | 36 | /** 37 | * Sends the newsletter to a list of subscribers. 38 | */ 39 | async send() { 40 | this.post.stats.members = this.subscribers.length; 41 | this.post.stats.newsletterStatus = 'Sending'; 42 | await this.post.update(); 43 | 44 | logDebug(logTags.Newsletter, 'Initializing sending emails.'); 45 | 46 | let totalEmailsSent = 0; 47 | const mailConfigs = await ProjectConfigs.mail(); 48 | let tierIds = this.post.isPaid 49 | ? [...this.post.tiers.map((tier) => tier.id)] 50 | : []; 51 | 52 | if (mailConfigs.length > 1 && this.subscribers.length > 1) { 53 | logDebug( 54 | logTags.Newsletter, 55 | 'More than one subscriber & email configs found, splitting the subscribers.', 56 | ); 57 | 58 | const chunkSize = Math.ceil( 59 | this.subscribers.length / mailConfigs.length, 60 | ); 61 | for (let i = 0; i < mailConfigs.length; i++) { 62 | // settings 63 | const mailConfig = mailConfigs[i]; 64 | const emailsPerBatch = mailConfig.batch_size ?? 10; 65 | const delayPerBatch = mailConfig.delay_per_batch ?? 1250; 66 | const chunkedSubscribers = this.subscribers.slice( 67 | i * chunkSize, 68 | (i + 1) * chunkSize, 69 | ); 70 | 71 | // create required batches and send. 72 | const batches = this.#createBatches( 73 | chunkedSubscribers, 74 | emailsPerBatch, 75 | ); 76 | 77 | // we need increment this stat as we are inside a loop. 78 | totalEmailsSent += await this.#processBatches( 79 | mailConfig, 80 | batches, 81 | chunkSize, 82 | tierIds, 83 | delayPerBatch, 84 | ); 85 | } 86 | } else { 87 | logDebug( 88 | logTags.Newsletter, 89 | 'Single user or email config found, sending email(s).', 90 | ); 91 | 92 | // settings 93 | const mailConfig = mailConfigs[0]; 94 | const emailsPerBatch = mailConfig.batch_size ?? 10; 95 | const delayPerBatch = mailConfig.delay_per_batch ?? 1250; 96 | 97 | // create required batches and send. 98 | const batches = this.#createBatches( 99 | this.subscribers, 100 | emailsPerBatch, 101 | ); 102 | totalEmailsSent = await this.#processBatches( 103 | mailConfig, 104 | batches, 105 | emailsPerBatch, 106 | tierIds, 107 | delayPerBatch, 108 | ); 109 | } 110 | 111 | // Update post status and save it. 112 | this.post.stats.newsletterStatus = 'Sent'; 113 | this.post.stats.emailsSent = totalEmailsSent; 114 | this.post.stats.emailsOpened = '0'.repeat(totalEmailsSent); 115 | await this.post.update(); 116 | 117 | logDebug(logTags.Newsletter, 'Email sending complete.'); 118 | } 119 | 120 | /** 121 | * Sends an email to a single subscriber. 122 | * 123 | * @param {*} transporter - The nodemailer transporter. 124 | * @param {Object} mailConfig - Configs for the email. 125 | * @param {Subscriber} subscriber - The subscriber to send the email to. 126 | * @param {number} index - The index of the subscriber in the subscribers array. 127 | * @param {string} html - The original HTML content of the email. 128 | * 129 | * @returns {Promise} - Promise resolving to true if email was sent successfully, false otherwise. 130 | */ 131 | async #sendEmailToSubscriber( 132 | transporter, 133 | mailConfig, 134 | subscriber, 135 | index, 136 | html, 137 | ) { 138 | const correctHTML = this.#correctHTML(html, subscriber, index); 139 | const customSubject = await this.#makeEmailSubject(subscriber); 140 | 141 | try { 142 | const info = await transporter.sendMail({ 143 | from: mailConfig.from, 144 | replyTo: mailConfig.reply_to, 145 | to: `"${subscriber.name}" <${subscriber.email}>`, 146 | subject: customSubject, 147 | html: correctHTML, 148 | list: { 149 | unsubscribe: { 150 | comment: 'Unsubscribe', 151 | url: this.unsubscribeLink.replace( 152 | '{MEMBER_UUID}', 153 | subscriber.uuid, 154 | ), 155 | }, 156 | }, 157 | headers: { 158 | 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 159 | }, 160 | }); 161 | 162 | return info.response.includes('250'); 163 | } catch (error) { 164 | logError(logTags.Newsletter, error); 165 | return false; 166 | } 167 | } 168 | 169 | /** 170 | * Generates the correct HTML content for the email by replacing placeholders. 171 | * 172 | * @param {string} html - The original HTML content. 173 | * @param {Subscriber} subscriber - The subscriber for whom the email is being sent. 174 | * @param {number} index - The index of the subscriber in the subscribers array. 175 | * 176 | * @returns {string} - The HTML content with placeholders replaced. 177 | */ 178 | #correctHTML(html, subscriber, index) { 179 | let source = html 180 | .replace(/{MEMBER_UUID}/g, subscriber.uuid) 181 | .replace('Jamie Larson', subscriber.name) // default value due to preview 182 | .replace('19 September 2013', subscriber.created) // default value due to preview 183 | .replace('jamie@example.com', subscriber.email) // default value due to preview 184 | .replace('free subscriber', `${subscriber.status} subscriber`) // default value due to preview 185 | .replace( 186 | '{TRACKING_PIXEL_LINK}', 187 | Miscellaneous.encode(`${this.post.id}_${index}`), 188 | ); 189 | 190 | if (subscriber.name === '') { 191 | // we use wrong class tag to keep the element visible, 192 | // use the right one to hide it as it is defined in the styles. 193 | source = source.replace( 194 | 'class="wrong-user-subscription-name-field"', 195 | 'class="user-subscription-name-field"', 196 | ); 197 | } 198 | 199 | return source; 200 | } 201 | 202 | /** 203 | * Parse the custom subject pattern if exists. 204 | * 205 | * @param {Subscriber} subscriber 206 | * @returns {Promise} The parsed subject. 207 | */ 208 | async #makeEmailSubject(subscriber) { 209 | // already cached => fast path. 210 | const newsletterConfig = await ProjectConfigs.newsletter(); 211 | let customSubject = 212 | newsletterConfig.custom_subject_pattern || this.post.title; 213 | 214 | customSubject = customSubject 215 | .replace('{{post_title}}', this.post.title) 216 | .replace('{{primary_author}}', this.post.primaryAuthor); 217 | 218 | // a post may not have a primary tag. 219 | if (customSubject.includes('{{primary_tag}}')) { 220 | if (this.post.primaryTag) 221 | customSubject = customSubject.replace( 222 | '{{primary_tag}}', 223 | this.post.primaryTag, 224 | ); 225 | else 226 | customSubject = customSubject.replace( 227 | /( • #| • )?{{primary_tag}}/, 228 | '', 229 | ); 230 | } 231 | 232 | if (customSubject.includes('{{newsletter_name}}')) { 233 | const nlsName = 234 | this.newsletterName ?? 235 | subscriber.newsletters.filter( 236 | (nls) => nls.status === 'active', 237 | )[0].name; 238 | customSubject = customSubject.replace( 239 | '{{newsletter_name}}', 240 | nlsName, 241 | ); 242 | } 243 | 244 | return customSubject; 245 | } 246 | 247 | /** 248 | * Creates and configures a nodemailer transporter. 249 | * 250 | * @param {Object} mailConfig - Config for the email. 251 | * 252 | * @returns {Promise<*>} - The configured transporter. 253 | */ 254 | async #transporter(mailConfig) { 255 | return nodemailer.createTransport({ 256 | secure: true, 257 | host: mailConfig.host, 258 | port: mailConfig.port, 259 | auth: { user: mailConfig.auth.user, pass: mailConfig.auth.pass }, 260 | }); 261 | } 262 | 263 | /** 264 | * Creates batches of given subscribers. 265 | * 266 | * @param {Subscriber[]} subscribers - The array of subscribers to be batched. 267 | * @param {number} batchSize - The number of subscribers in each batch. 268 | * 269 | * @returns {Subscriber[][]} An array of subscriber arrays, where each inner array is a batch. 270 | */ 271 | #createBatches(subscribers, batchSize) { 272 | const batches = []; 273 | if (subscribers.length <= batchSize) { 274 | return [subscribers]; 275 | } 276 | 277 | for (let i = 0; i < subscribers.length; i += batchSize) { 278 | batches.push(subscribers.slice(i, i + batchSize)); 279 | } 280 | 281 | logDebug(logTags.Newsletter, `Created ${batches.length} batches.`); 282 | return batches; 283 | } 284 | 285 | /** 286 | * Send emails in batches with appropriate delay. 287 | * 288 | * @returns {Promise} Total emails sent. 289 | */ 290 | async #processBatches( 291 | mailConfig, 292 | batches, 293 | chunkSize, 294 | tierIds, 295 | delayBetweenBatches, 296 | ) { 297 | let emailsSent = 0; 298 | const totalBatchLength = batches.length; 299 | const transporter = await this.#transporter(mailConfig); 300 | 301 | for (let batchIndex = 0; batchIndex < totalBatchLength; batchIndex++) { 302 | const batch = batches[batchIndex]; 303 | const startIndex = batchIndex * chunkSize; 304 | 305 | const promises = [ 306 | ...batch.map((subscriber, index) => { 307 | const globalIndex = startIndex + index; 308 | const contentToSend = this.post.isPaid 309 | ? subscriber.isPaying(tierIds) 310 | ? this.fullContent 311 | : this.partialContent ?? this.fullContent 312 | : this.fullContent; 313 | return this.#sendEmailToSubscriber( 314 | transporter, 315 | mailConfig, 316 | subscriber, 317 | globalIndex, 318 | contentToSend, 319 | ); 320 | }), 321 | ]; 322 | 323 | const batchResults = await Promise.allSettled(promises); 324 | emailsSent += batchResults.filter( 325 | (result) => result.value === true, 326 | ).length; 327 | 328 | if (totalBatchLength > 1) { 329 | logToConsole( 330 | logTags.Newsletter, 331 | `Batch ${batchIndex + 1}/${totalBatchLength} complete.`, 332 | ); 333 | } 334 | 335 | if (batchIndex < batches.length - 1) 336 | await Miscellaneous.sleep(delayBetweenBatches); 337 | } 338 | 339 | return emailsSent; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /utils/misc.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import express from 'express'; 3 | import jwt from 'jsonwebtoken'; 4 | import Files from './data/files.js'; 5 | import cookieSession from 'cookie-session'; 6 | import fileUpload from 'express-fileupload'; 7 | import ProjectConfigs from './data/configs.js'; 8 | import { extract } from '@extractus/oembed-extractor'; 9 | import { logDebug, logError, logTags } from './log/logger.js'; 10 | 11 | /** 12 | * This class provides general utility methods. 13 | */ 14 | export default class Miscellaneous { 15 | /** 16 | * Set up miscellaneous middlewares and configurations for a given express app instance. 17 | * 18 | * @param {Express} expressApp 19 | * @returns {Promise} Nothing. 20 | */ 21 | static async setup(expressApp) { 22 | expressApp.set('view engine', 'ejs'); 23 | expressApp.use(express.static('public')); 24 | expressApp.use(express.json({ limit: '50mb' })); 25 | expressApp.use(express.urlencoded({ extended: true, limit: '50mb' })); 26 | expressApp.use( 27 | fileUpload({ 28 | safeFileNames: true, 29 | preserveExtension: true, 30 | useTempFiles: false, 31 | }), 32 | ); 33 | 34 | // login sessions. 35 | expressApp.use( 36 | cookieSession({ 37 | name: 'ghosler', 38 | maxAge: 24 * 60 * 60 * 1000, 39 | secret: crypto.randomUUID(), // dynamic secret, always invalidated on a restart. 40 | }), 41 | ); 42 | 43 | // Safeguard 44 | await Files.makeFilesDir(); 45 | 46 | logDebug(logTags.Express, '============================'); 47 | logDebug(logTags.Express, 'View-engine set!'); 48 | 49 | // the site might be behind a proxy. 50 | expressApp.enable('trust proxy'); 51 | 52 | // add common data for response. 53 | expressApp.use(async (_, res, next) => { 54 | // add project version info for all render pages. 55 | res.locals.version = ProjectConfigs.ghoslerVersion; 56 | 57 | // add no robots header tag to all. 58 | res.header('X-Robots-Tag', 'noindex, nofollow'); 59 | 60 | // provide ghosler.url to every view. 61 | const ghosler = await ProjectConfigs.ghosler(); 62 | res.locals.ghoslerUrl = ghosler.url || ''; 63 | 64 | // finally move ahead. 65 | next(); 66 | }); 67 | 68 | logDebug(logTags.Express, 'Robots managed!'); 69 | 70 | // password protect, ignore a few endpoints. 71 | expressApp.all('*', async (req, res, next) => { 72 | const path = req.path; 73 | const isUnrestrictedPath = /\/login$|\/preview$|\/track/.test(path); 74 | const isPostPublish = 75 | req.method === 'POST' && /\/published$/.test(path); 76 | 77 | if (isUnrestrictedPath || isPostPublish) return next(); 78 | 79 | if (req.session.user) return next(); 80 | 81 | // redirect to page the user wanted to go to, after auth. 82 | const redirect = 83 | path !== '/' ? `?redirect=${encodeURIComponent(path)}` : ''; 84 | res.status(401).redirect(`/login${redirect}`); 85 | }); 86 | } 87 | 88 | /** 89 | * Generates a 1x1 pixel transparent GIF image. 90 | * 91 | * @returns {Buffer} A Buffer object containing the binary data of a 1x1 pixel transparent GIF. 92 | */ 93 | static trackingPixel() { 94 | return Buffer.from( 95 | 'R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 96 | 'base64', 97 | ); 98 | } 99 | 100 | /** 101 | * Checks if the user is authenticated via Basic Auth. 102 | * 103 | * @param {*} req The express request object. 104 | * @returns {Promise<{level: string, message: string}>} Authentication status. 105 | */ 106 | static async authenticated(req) { 107 | if (!req.body || !req.body.username || !req.body.password) { 108 | return { 109 | level: 'error', 110 | message: 'Please enter both Username & Password!', 111 | }; 112 | } 113 | 114 | const { username, password } = req.body; 115 | const ghosler = await ProjectConfigs.ghosler(); 116 | 117 | if ( 118 | username === ghosler.auth.user && 119 | this.hash(password) === ghosler.auth.pass 120 | ) { 121 | req.session.user = ghosler.auth.user; 122 | return { level: 'success', message: 'Successfully logged in!' }; 123 | } else { 124 | return { 125 | level: 'error', 126 | message: 'Username or Password does not match!', 127 | }; 128 | } 129 | } 130 | 131 | /** 132 | * Converts an ISO date string to a more readable "DD MMM YYYY" format. 133 | * 134 | * @param {string} dateString The ISO date string to be formatted. 135 | * @returns {string} The formatted date in "DD MMM YYYY" format. 136 | */ 137 | static formatDate(dateString) { 138 | const date = new Date(dateString); 139 | 140 | const day = String(date.getDate()).padStart(2, '0'); 141 | const months = [ 142 | 'Jan', 143 | 'Feb', 144 | 'Mar', 145 | 'Apr', 146 | 'May', 147 | 'Jun', 148 | 'Jul', 149 | 'Aug', 150 | 'Sep', 151 | 'Oct', 152 | 'Nov', 153 | 'Dec', 154 | ]; 155 | const month = months[date.getMonth()]; 156 | const year = date.getFullYear(); 157 | 158 | return `${day} ${month} ${year}`; 159 | } 160 | 161 | /** 162 | * Converts to hh:mm:ss.xx format. 163 | * 164 | * @param durationInSeconds 165 | * @returns {string} Time in hh:mm:ss.xx format. 166 | */ 167 | static formatDuration(durationInSeconds) { 168 | const totalSeconds = Number(durationInSeconds); 169 | const hours = Math.floor(totalSeconds / 3600); 170 | const minutes = Math.floor((totalSeconds % 3600) / 60); 171 | const seconds = Math.floor(totalSeconds % 60); 172 | 173 | let formattedDuration = `${String(seconds).padStart(2, '0')}`; 174 | if (minutes > 0 || hours > 0 || totalSeconds < 60) { 175 | formattedDuration = `${String(minutes).padStart(2, '0')}:${formattedDuration}`; 176 | } 177 | 178 | if (hours > 0) { 179 | formattedDuration = `${hours}:${formattedDuration}`; 180 | } 181 | 182 | return formattedDuration; 183 | } 184 | 185 | /** 186 | * Check if a given image url is from Unsplash. 187 | * 188 | * @param imageUrl - The url of the image to check. 189 | * @returns {boolean} True if the host name matches unsplash. 190 | */ 191 | static detectUnsplashImage(imageUrl) { 192 | return /images\.unsplash\.com/.test(imageUrl); 193 | } 194 | 195 | /** 196 | * Removes the tracking if it exists and returns a clean url. 197 | * 198 | * @param {string} url - Url to clean. 199 | * @returns {string} 200 | */ 201 | static getOriginalUrl(url) { 202 | let cleanUrl = url; 203 | if (cleanUrl.includes('/track/link?')) { 204 | const redirectIndex = cleanUrl.indexOf('&redirect='); 205 | if (redirectIndex !== -1) { 206 | cleanUrl = cleanUrl.slice(redirectIndex + '&redirect='.length); 207 | cleanUrl = decodeURIComponent(cleanUrl); 208 | } 209 | } 210 | 211 | return cleanUrl; 212 | } 213 | 214 | /** 215 | * Adds the tracking prefix to a given url. 216 | * 217 | * @param {string} url - Url to track. 218 | * @param {string} postId - The post id this url belongs to. 219 | * @returns {Promise} 220 | */ 221 | static async addTrackingToUrl(url, postId) { 222 | const ghosler = await ProjectConfigs.ghosler(); 223 | return `${ghosler.url}/track/link?postId=${postId}&redirect=${url}`; 224 | } 225 | 226 | /** 227 | * Get thumbnail for a given oembed provided from url. 228 | * 229 | * @param url 230 | * @returns {Promise} 231 | */ 232 | static async thumbnail(url) { 233 | const extractedInfo = await extract(url); 234 | return extractedInfo.thumbnail_url; 235 | } 236 | 237 | /** 238 | * Sleep for a given period of time. 239 | * 240 | * @param {number} ms Milliseconds to sleep. 241 | * @returns {Promise} 242 | */ 243 | static sleep(ms) { 244 | return new Promise((resolve) => setTimeout(resolve, ms)); 245 | } 246 | 247 | /** 248 | * Encodes a given string to Base64 format. 249 | * 250 | * @param {string} data The string to be encoded. 251 | * @returns {string} The Base64 encoded string. 252 | */ 253 | static encode(data) { 254 | return btoa(data); 255 | } 256 | 257 | /** 258 | * Decodes a Base64 encoded string. 259 | * 260 | * @param {string} data The Base64 encoded string to be decoded. 261 | * @returns {string} The decoded string. 262 | */ 263 | static decode(data) { 264 | return atob(data); 265 | } 266 | 267 | /** 268 | * Hash a given input with md5. 269 | * 270 | * @param data The data to hash. 271 | * @returns {string} MD5 hashed value of the given data. 272 | */ 273 | static hash(data) { 274 | return crypto.createHash('md5').update(data).digest('hex'); 275 | } 276 | 277 | /** 278 | * Validate if the incoming webhook contains valid secret key. 279 | * 280 | * @param {express.Request} request - The express request object. 281 | * @returns {Promise} True if valid, false otherwise. 282 | */ 283 | static async isPostSecure(request) { 284 | const payload = JSON.stringify(request.body); 285 | const ghostSecretSignature = (await ProjectConfigs.ghost()).secret; 286 | const signatureWithDateHeader = request.headers['x-ghost-signature']; 287 | 288 | // Secret set on Ghosler but not recd. in the request headers. 289 | if (ghostSecretSignature && !signatureWithDateHeader) { 290 | logError( 291 | logTags.Express, 292 | "The 'X-Ghost-Signature' header not found in the request. Did you setup the Secret Key correctly?", 293 | ); 294 | return false; 295 | } 296 | 297 | const signatureAndTimeStamp = signatureWithDateHeader.split(', '); 298 | 299 | // @see: https://github.com/TryGhost/Ghost/blob/efb2b07c601cd557976bcbe12633f072da5c22a7/ghost/core/core/server/services/webhooks/WebhookTrigger.js#L98 300 | const signature = signatureAndTimeStamp[0].replace('sha256=', ''); 301 | const timeStamp = parseInt(signatureAndTimeStamp[1].replace('t=', '')); 302 | if (!signature || isNaN(timeStamp)) { 303 | logError( 304 | logTags.Express, 305 | "Either the signature or the timestamp in the 'X-Ghost-Signature' header is not valid or doesn't exist.", 306 | ); 307 | return false; 308 | } 309 | 310 | const maxTimeDiff = 5 * 60 * 1000; // 5 minutes 311 | if (Math.abs(Date.now() - timeStamp) > maxTimeDiff) { 312 | logError( 313 | logTags.Express, 314 | "The timestamp in the 'X-Ghost-Signature' header exceeds 5 minutes.", 315 | ); 316 | return false; 317 | } 318 | 319 | /** 320 | * Build signature for versions below `Ghost:5.87.1`. 321 | */ 322 | const expectedOldSignature = this.#createHmac( 323 | ghostSecretSignature, 324 | payload, 325 | ); 326 | 327 | /** 328 | * Build signature with new logic for `Ghost:5.87.1` & above. 329 | * @see https://github.com/TryGhost/Ghost/pull/20500 330 | */ 331 | const expectedNewSignature = this.#createHmac( 332 | ghostSecretSignature, 333 | `${payload}${timeStamp}`, 334 | ); 335 | 336 | // Check if either of the signatures match 337 | if ( 338 | signature === expectedOldSignature || 339 | signature === expectedNewSignature 340 | ) { 341 | return true; 342 | } else { 343 | logError( 344 | logTags.Express, 345 | "The signature in the 'X-Ghost-Signature' header is not valid.", 346 | ); 347 | return false; 348 | } 349 | } 350 | 351 | /** 352 | * Generate a token for Ghost request authentication. 353 | * 354 | * @param {String} key - API key to sign JWT with 355 | * @param {String} audience - token audience 356 | * 357 | * @returns {string} Token for the web requests. 358 | */ 359 | static ghostToken(key, audience) { 360 | const [id, secret] = key.split(':'); 361 | 362 | return jwt.sign({}, Buffer.from(secret, 'hex'), { 363 | keyid: id, 364 | algorithm: 'HS256', 365 | expiresIn: '5m', 366 | audience, 367 | }); 368 | } 369 | 370 | /** 371 | * Creates a signature based on given inputs. 372 | * 373 | * @param {string} secret - The ghost secret key. 374 | * @param {string} payload - The payload to create the hash for. 375 | * 376 | * @returns {string} The generated HMAC hex digest. 377 | */ 378 | static #createHmac(secret, payload) { 379 | return crypto 380 | .createHmac('sha256', secret) 381 | .update(payload) 382 | .digest('hex'); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /views/dashboard/analytics.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Analytics - Ghosler 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%- include('../partials/common/header.ejs') %> 12 | 13 |
14 | 21 | 22 | <% if (analytics.length === 0) { %> 23 |

Newsletter analytics will show up once you publish a 24 | post after setting up Ghosler.
Also make sure you've set up the Webhook with Publish 25 | Post event on Ghost integration's screen.

26 | <% } else { %> 27 | 28 | 29 |
30 |

Posts: <%= overview.posts; %>

32 |

Total Emails Sent: <%= overview.sent; %>

34 |

Total Emails Opened: <%= overview.opens; %> 36 | <% if (overview.sent > 0) { %> 37 | (<%= Math.round(overview.opens / overview.sent * 100) %>%) 38 | <% } %> 39 |

40 |
41 | 42 |

Here's an overview of your most recent posts.

43 | 44 |

Note: If you wish to resend a newsletter, 45 | you can do so by 46 | deleting the post here and then re-publishing it from the Ghost dashboard. 47 |

48 | 49 | 50 | <% analytics.sort((a, b) => new Date(b.date) - new Date(a.date)); %> 51 | 52 | 53 | 157 | 158 | 159 |
160 | <% analytics.forEach(element => { %> 161 |
162 |
163 | <%= element.title %> 165 |
By <%= element.author %>, <%= element.date %>
166 |
167 | 168 |
Members: <%= element.stats.members %>
169 |
170 | 171 |
Emails Sent: <%= element.stats.emailsSent %>
172 |
173 | 174 |
Emails Opened: <%= element.stats.emailsOpened %>
175 |
176 | 177 |
Status: <%= element.stats.newsletterStatus %>
178 |
179 | 180 |
Newsletter: 181 | <% if (element.stats.newsletterName) { %> 182 | <%= element.stats.newsletterName %> 183 | <% } else { %> 184 | N/A 185 | <% } %> 186 |
187 | 188 |
189 |
190 | 197 | 198 | 203 |
204 |
205 |
206 | <% }); %> 207 |
208 | <% } %> 209 |
210 | 211 | <%- include('../partials/common/footer.ejs') %> 212 | 213 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /utils/data/configs.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import { fileURLToPath } from 'url'; 4 | import Miscellaneous from '../misc.js'; 5 | import { logError, logTags } from '../log/logger.js'; 6 | 7 | /** 8 | * A class to handle project configuration settings. 9 | */ 10 | export default class ProjectConfigs { 11 | /** 12 | * A cached, in-memory object of our configuration. 13 | * 14 | * @type {{}} 15 | */ 16 | static #cachedSettings = {}; 17 | 18 | /** 19 | * Current ghosler version from `package.json` file. 20 | * 21 | * @type {string} 22 | */ 23 | static ghoslerVersion = ''; 24 | 25 | /** 26 | * Retrieves the Ghosler (Ghost Newsletter Service) configuration. 27 | * 28 | * @returns {Promise} The Ghosler configuration object. 29 | * @throws {Error} If the configuration is missing or invalid. 30 | */ 31 | static async ghosler() { 32 | if (this.#cachedSettings.ghosler) { 33 | return this.#cachedSettings.ghosler; 34 | } 35 | 36 | const configs = await this.#getConfigs(); 37 | return configs.ghosler; 38 | } 39 | 40 | /** 41 | * Retrieves the ghost site configuration. 42 | * 43 | * @returns {Promise} The site configuration object. 44 | * @throws {Error} If the configuration is missing or invalid. 45 | */ 46 | static async ghost() { 47 | if (this.#cachedSettings.ghost) { 48 | return this.#cachedSettings.ghost; 49 | } 50 | 51 | const configs = await this.#getConfigs(); 52 | return configs.ghost; 53 | } 54 | 55 | /** 56 | * Retrieves the newsletter configuration for customization. 57 | * 58 | * @returns {Promise} The newsletter configuration object. 59 | * @throws {Error} If the configuration is missing or invalid. 60 | */ 61 | static async newsletter() { 62 | if (this.#cachedSettings.newsletter) { 63 | return this.#cachedSettings.newsletter; 64 | } 65 | 66 | const configs = await this.#getConfigs(); 67 | return configs.newsletter; 68 | } 69 | 70 | /** 71 | * Retrieves the newsletter configuration for customization. 72 | * 73 | * @returns {Promise} The newsletter configuration object. 74 | * @throws {Error} If the configuration is missing or invalid. 75 | */ 76 | static async customTemplate() { 77 | if (this.#cachedSettings.custom_template) { 78 | return this.#cachedSettings.custom_template; 79 | } 80 | 81 | const configs = await this.#getConfigs(); 82 | return configs.custom_template; 83 | } 84 | 85 | /** 86 | * Retrieves the mail configuration. 87 | * 88 | * @returns {Promise} The mail configuration object. 89 | * @throws {Error} If the configuration is missing or invalid. 90 | */ 91 | static async mail() { 92 | if (this.#cachedSettings.mail) { 93 | return this.#cachedSettings.mail; 94 | } 95 | 96 | const configs = await this.#getConfigs(); 97 | return configs.mail; 98 | } 99 | 100 | /** 101 | * Get all the configurations. 102 | * 103 | * @returns {Promise} The saved configurations. 104 | * @throws {Error} If there's an issue reading or parsing the configuration file. 105 | */ 106 | static async all() { 107 | return await this.#getConfigs(); 108 | } 109 | 110 | /** 111 | * Resets & updates the config cache. 112 | */ 113 | static async reset() { 114 | await this.all(); 115 | } 116 | 117 | /** 118 | * Internal method to read and parse the configuration file. 119 | * 120 | * @returns {Promise} Parsed configuration data. 121 | * @throws {Error} If there's an issue reading or parsing the configuration file. 122 | */ 123 | static async #getConfigs() { 124 | this.#fetchGhoslerVersion(); 125 | 126 | try { 127 | const configFilePath = await this.#getConfigFilePath(); 128 | const fileContents = await fs.readFile(configFilePath, 'utf8'); 129 | const configs = JSON.parse(fileContents); 130 | 131 | // Update the cached settings. 132 | this.#cachedSettings = configs; 133 | 134 | return configs; 135 | } catch (error) { 136 | logError(logTags.Configs, error); 137 | return {}; 138 | } 139 | } 140 | 141 | /** 142 | * Updates the user configurations. 143 | * 144 | * @param {FormData} formData The updated info. 145 | * @param {boolean} isPasswordUpdate Whether this a password update. 146 | * @returns {Promise<{level: string, message: string}>} True if updated, false if something went wrong. 147 | */ 148 | static async update(formData, isPasswordUpdate = false) { 149 | const configs = await this.all(); 150 | 151 | if (isPasswordUpdate) { 152 | const currentPass = formData['ghosler.auth.pass']; 153 | if (Miscellaneous.hash(currentPass) !== configs.ghosler.auth.pass) { 154 | return { 155 | level: 'error', 156 | message: 'Current password not correct.', 157 | }; 158 | } 159 | 160 | const newPass = formData['ghosler.auth.new_pass']; 161 | const newPassAgain = formData['ghosler.auth.new_pass_confirm']; 162 | 163 | if (newPass.toString().length < 8) { 164 | return { 165 | level: 'error', 166 | message: 167 | 'New password should at-least be 8 characters long.', 168 | }; 169 | } 170 | 171 | if (newPassAgain !== newPass) { 172 | return { 173 | level: 'error', 174 | message: 175 | 'New Password & Confirmation Password do not match!', 176 | }; 177 | } 178 | 179 | configs.ghosler.auth.pass = Miscellaneous.hash(newPass); 180 | 181 | const success = await this.#write(configs); 182 | if (success) { 183 | // update password in cache. 184 | this.#cachedSettings = configs; 185 | return { level: 'success', message: 'Password updated!' }; 186 | } else 187 | return { 188 | level: 'error', 189 | message: 190 | 'Error updating password, check error logs for more info.', 191 | }; 192 | } 193 | 194 | const url = formData['ghosler.url']; 195 | const user = formData['ghosler.auth.user']; 196 | 197 | const ghostUrl = formData['ghost.url']; 198 | const ghostAdminKey = formData['ghost.key']; 199 | const ghostAdminSecret = formData['ghost.secret']; 200 | const newsletterTrackLinks = formData['newsletter.track_links']; 201 | const newsletterCenterTitle = formData['newsletter.center_title']; 202 | const newsletterShowExcerpt = formData['newsletter.show_excerpt']; 203 | const newsletterShowFeedback = formData['newsletter.show_feedback']; 204 | const newsletterShowComments = formData['newsletter.show_comments']; 205 | const newsletterShowLatestPosts = 206 | formData['newsletter.show_latest_posts']; 207 | const newsletterShowSubscription = 208 | formData['newsletter.show_subscription']; 209 | const newsletterShowFeaturedImage = 210 | formData['newsletter.show_featured_image']; 211 | const newsletterFooterContent = formData['newsletter.footer_content']; 212 | const newsletterCustomSubject = 213 | formData['newsletter.custom_subject_pattern']; 214 | const newsletterPoweredByGhost = 215 | formData['newsletter.show_powered_by_ghost']; 216 | const newsletterPoweredByGhosler = 217 | formData['newsletter.show_powered_by_ghosler']; 218 | const customTemplateEnabled = formData['custom_template.enabled']; 219 | 220 | const email = formData['email']; 221 | if (!Array.isArray(email) || email.length === 0) { 222 | return { 223 | level: 'error', 224 | message: 'Add at-least one email configuration.', 225 | }; 226 | } 227 | 228 | configs.ghosler.auth.user = user; 229 | if (configs.ghosler.url !== url) configs.ghosler.url = url; 230 | 231 | if ( 232 | ghostUrl === '' || 233 | ghostAdminKey === '' || 234 | ghostAdminSecret === '' 235 | ) { 236 | return { 237 | level: 'error', 238 | message: 'Ghost URL, Admin API Key or Secret is missing.', 239 | }; 240 | } 241 | 242 | if (ghostAdminSecret.toString().length < 8) { 243 | return { 244 | level: 'error', 245 | message: 'Secret should at-least be 8 characters long.', 246 | }; 247 | } 248 | 249 | // ghost 250 | configs.ghost.url = ghostUrl; 251 | configs.ghost.key = ghostAdminKey; 252 | configs.ghost.secret = ghostAdminSecret; 253 | 254 | // newsletter 255 | configs.newsletter.track_links = newsletterTrackLinks === 'on' ?? true; 256 | configs.newsletter.center_title = 257 | newsletterCenterTitle === 'on' ?? false; 258 | configs.newsletter.show_excerpt = 259 | newsletterShowExcerpt === 'on' ?? false; 260 | configs.newsletter.show_feedback = 261 | newsletterShowFeedback === 'on' ?? true; 262 | configs.newsletter.show_comments = 263 | newsletterShowComments === 'on' ?? true; 264 | configs.newsletter.show_latest_posts = 265 | newsletterShowLatestPosts === 'on' ?? false; 266 | configs.newsletter.show_subscription = 267 | newsletterShowSubscription === 'on' ?? false; 268 | configs.newsletter.show_featured_image = 269 | newsletterShowFeaturedImage === 'on' ?? true; 270 | configs.newsletter.show_powered_by_ghost = 271 | newsletterPoweredByGhost === 'on' ?? true; 272 | configs.newsletter.show_powered_by_ghosler = 273 | newsletterPoweredByGhosler === 'on' ?? true; 274 | configs.newsletter.footer_content = newsletterFooterContent; 275 | configs.newsletter.custom_subject_pattern = newsletterCustomSubject; 276 | 277 | // may not exist on an update, so create one anyway. 278 | if (!configs.custom_template) configs.custom_template = {}; 279 | 280 | configs.custom_template.enabled = 281 | customTemplateEnabled === 'on' ?? false; 282 | 283 | // mail configurations 284 | configs.mail = [ 285 | ...email.map( 286 | ({ 287 | batch_size, 288 | delay_per_batch, 289 | auth_user, 290 | auth_pass, 291 | ...rest 292 | }) => { 293 | return { 294 | ...rest, 295 | batch_size: parseInt(batch_size), 296 | delay_per_batch: parseInt(delay_per_batch), 297 | auth: { 298 | user: auth_user, 299 | pass: auth_pass, 300 | }, 301 | }; 302 | }, 303 | ), 304 | ]; 305 | 306 | const success = await this.#write(configs); 307 | if (success) { 308 | // update the config. cache. 309 | this.#cachedSettings = configs; 310 | return { level: 'success', message: 'Settings updated!' }; 311 | } else 312 | return { 313 | level: 'error', 314 | message: 315 | 'Error updating settings, check error logs for more info.', 316 | }; 317 | } 318 | 319 | /** 320 | * Updates the custom template file. 321 | * 322 | * @param {UploadedFile | UploadedFile[]} templateFile The uploaded custom template file. 323 | * @returns {Promise<{level: string, message: string}>} True if updated, false if something went wrong. 324 | */ 325 | static async updateCustomTemplate(templateFile) { 326 | try { 327 | await templateFile.mv('./configuration/custom-template.ejs'); 328 | return { level: 'success', message: 'Template file uploaded!' }; 329 | } catch (error) { 330 | logError(logTags.Configs, error); 331 | return { 332 | level: 'error', 333 | message: 334 | 'Error updating settings, check error logs for more info.', 335 | }; 336 | } 337 | } 338 | 339 | /** 340 | * Update the file with latest configs. 341 | * 342 | * @param configs Configs to save. 343 | * @returns {Promise} True if updated, false if something went wrong. 344 | */ 345 | static async #write(configs) { 346 | const filePath = await this.#getConfigFilePath(); 347 | 348 | try { 349 | await fs.writeFile(filePath, JSON.stringify(configs), 'utf8'); 350 | return true; 351 | } catch (error) { 352 | logError(logTags.Configs, error); 353 | return false; 354 | } 355 | } 356 | 357 | /** 358 | * Retrieves the file path of the configuration file. 359 | * 360 | * @returns {Promise} The file path of the debug or production configuration file. 361 | */ 362 | static async #getConfigFilePath() { 363 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 364 | 365 | const debugConfigPath = path.resolve( 366 | __dirname, 367 | '../../configuration/config.local.json', 368 | ); 369 | const prodConfigPath = path.resolve( 370 | __dirname, 371 | '../../configuration/config.production.json', 372 | ); 373 | 374 | try { 375 | await fs.access(debugConfigPath); 376 | return debugConfigPath; 377 | } catch { 378 | return prodConfigPath; 379 | } 380 | } 381 | 382 | /** 383 | * Updates the {@link ghoslerVersion} from the `package.json` file. 384 | */ 385 | static #fetchGhoslerVersion() { 386 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 387 | const packageFilePath = path.resolve(__dirname, '../../package.json'); 388 | fs.readFile(packageFilePath, 'utf8') 389 | .then((fileContent) => { 390 | this.ghoslerVersion = JSON.parse(fileContent).version; 391 | }) 392 | .catch((_) => (this.ghoslerVersion = '')); 393 | } 394 | } 395 | --------------------------------------------------------------------------------