├── 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 |
2 |
3 |
Ghosler
4 | by
5 |
ItzNotABug
6 |
7 |
Contributions and feedback are welcome!
8 |
9 |
--------------------------------------------------------------------------------
/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 |
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 |
12 |
13 | Username
14 |
16 |
17 |
18 |
19 |
Change Password
21 |
22 |
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 |
11 |
12 |
13 | checked
16 | <% } %>
17 | >
18 | Use Custom Template
19 |
20 |
21 |
32 |
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 |
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 |
17 |
18 | Home
19 | >
20 | Logs
21 | >
22 | <%= logLevel %>
23 |
24 |
25 |
26 |
27 |
28 |
Log level: <%= logLevel %>
29 | <% if (content) { %>
30 |
35 | Clear
36 |
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 |
15 |
16 | Home
17 | >
18 | Settings
19 | >
20 | Profile Password
21 |
22 |
23 |
24 | <%- include('../partials/common/message.ejs') %>
25 |
26 |
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 |
15 |
16 | Home
17 | >
18 | Import, Export
19 |
20 |
21 |
22 | <%- include('../partials/common/message.ejs') %>
23 |
24 |
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 |
15 |
16 | Home
17 | >
18 | Settings
19 | >
20 | Upload Custom Template
21 |
22 |
23 |
24 | <%- include('../partials/common/message.ejs') %>
25 |
26 |
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 |
12 |
13 | URL
14 |
16 |
17 |
18 |
19 | Admin Key
20 |
22 |
23 |
24 |
25 |
Secret Key
26 |
28 |
29 |
30 |
Click to Generate a Secure Key
34 |
35 |
36 | To use a new Secret, delete the Webhook from the integration first.
37 | Click
38 | here for deletion.
42 |
43 |
44 |
45 |
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 |
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 |
15 |
16 | Home
17 | >
18 | Newsletters
19 |
20 |
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 |
36 |
37 |
38 |
39 | Name
40 | Action
41 |
42 |
43 |
44 | <% newsletters.forEach((element, index, array) => { %>
45 |
46 |
47 | <%= element.name; %>
48 | <%= element.description; %>
49 |
50 |
51 |
62 |
63 |
64 | <% }); %>
65 |
66 |
67 |
68 |
69 |
70 |
71 | <% newsletters.forEach(element => { %>
72 |
73 |
74 |
<%= element.name; %>
75 |
<%= element.description %>
76 |
77 |
78 |
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 |
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 |
12 |
13 | <% configs.mail.forEach((config, index) => { %>
14 |
15 |
17 |
<%= (index + 1) + ". " + config.from.replaceAll('\'', ''); %>
18 |
20 |
22 |
23 |
24 |
25 |
103 |
104 | <% }); %>
105 |
106 |
Add New
108 |
109 |
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 |
36 |
37 | Home
38 | >
39 | Analytics
40 | >
41 | <%= post ? post.title : 'NA'; %>
42 |
43 |
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 |
75 |
Sentiment reflects the ratio of members who liked versus disliked this post in
76 | the
77 | newsletter.
78 |
Key Insights:
79 |
80 | Likes indicate topics that resonate well with the readers.
81 | Dislikes help identify topics of less interest.
82 | Engagement levels can guide future content creation.
83 |
84 |
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 |
101 |
102 |
103 |
104 | Links
105 | Clicks
106 |
107 |
108 |
109 | <% post.stats.postContentTrackedLinks.forEach((element, index, array) => { %>
110 |
111 | <% const link = Object.keys(element)[0]; %>
112 | <% const clicks = element[link]; %>
113 |
114 | <%= link.split(/[?#]/)[0]; %>
116 | <%= clicks; %>
117 |
118 | <% }); %>
119 |
120 |
121 |
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 |
124 |
125 | Home
126 | >
127 | Settings
128 |
129 |
130 |
131 | <% if (!configs) { %>
132 | Unable to load settings.
133 | <% } else { %>
134 |
135 | <%- include('../partials/common/message.ejs') %>
136 |
137 |
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 |
12 |
13 |
14 | checked
17 | <% } %>
18 | >
19 | Center Title
20 |
21 |
22 |
23 |
24 | checked
27 | <% } %>
28 | >
29 | Show Excerpt
30 |
31 |
32 |
33 |
46 |
47 |
48 |
49 | checked
52 | <% } %>
53 | >
54 | Show Feedback
55 |
56 |
57 |
58 |
59 | checked
62 | <% } %>
63 | >
64 | Show Comments
65 |
66 |
67 |
68 |
69 | checked
72 | <% } %>
73 | >
74 | Show Latest Posts
75 |
76 |
77 |
78 |
79 | checked
82 | <% } %>
83 | >
84 | Show Subscription
85 |
86 |
87 |
88 |
89 | checked
92 | <% } %>
93 | >
94 | Show Featured Image
95 |
96 |
97 |
98 |
99 | checked
102 | <% } %>
103 | >
104 | Show 'Powered by Ghost'
105 |
106 |
107 |
108 |
109 | checked
112 | <% } %>
113 | >
114 | Show 'Newsletter
115 | delivered using Ghosler'
116 |
117 |
118 |
119 |
145 |
146 |
147 |
148 | Footer Content
149 |
151 |
152 |
153 |
Note: Save Changes before Preview
154 |
155 |
Preview
157 |
158 |
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 |
15 |
16 | Home
17 | >
18 | Analytics
19 |
20 |
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 |
54 |
55 |
56 |
57 | Title
58 | Newsletter
59 | Members
60 | Emails Sent
61 | Emails Opened
62 | Status
63 | Action
64 |
65 |
66 |
67 | <% analytics.forEach((element, index, array) => { %>
68 |
69 |
70 | <%= element.title %>
72 |
73 | <% var date = new Date(element.date); %>
74 | <% var formattedDate = date.getDate() + '/' + (date.getMonth() + 1) + '/' + date.getFullYear(); %>
75 | By <%= element.author %>,
76 | By
77 | <%= element.author %>,
78 | <%= formattedDate %>
79 |
80 |
81 |
82 |
83 | <% if (element.stats.newsletterName) { %>
84 | <%= element.stats.newsletterName %>
85 | <% } else { %>
86 | N/A
87 | <% } %>
88 |
89 |
90 | <%= element.stats.members %>
91 | <%= element.stats.emailsSent %>
92 | <%= element.stats.emailsOpened %>
93 | <%= element.stats.newsletterStatus %>
94 |
95 |
96 |
97 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
110 |
111 |
112 |
Available Actions
113 |
117 |
119 |
120 |
121 |
124 | <% if (element.stats.newsletterStatus === 'Unsent') { %>Send
125 | <% } else { %>Info.
126 | <% } %>
127 |
128 |
131 | Delete
132 |
133 |
134 |
135 |
136 |
137 |
138 |
141 | <% if (element.stats.newsletterStatus === 'Unsent') { %>Send
142 | <% } else { %>Info.
143 | <% } %>
144 |
145 |
148 | Delete
149 |
150 |
151 |
152 |
153 | <% }); %>
154 |
155 |
156 |
157 |
158 |
159 |
160 | <% analytics.forEach(element => { %>
161 |
162 |
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 |
193 | <% if (element.stats.newsletterStatus === 'Unsent') { %>Send
194 | <% } else { %>Info.
195 | <% } %>
196 |
197 |
198 |
201 | Delete
202 |
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 |
--------------------------------------------------------------------------------