0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Migrated packages from Utils

refs https://github.com/TryGhost/Toolbox/issues/354

- the Utils repo is being split up and we're bringing these packages
  into Ghost
- this commit merges the history of the relevant packages in the Utils
  directory into Ghost
This commit is contained in:
Daniel Lockyer 2022-07-26 14:45:37 +02:00
commit 3526154377
No known key found for this signature in database
GPG key ID: D21186F0B47295AD
244 changed files with 11481 additions and 0 deletions

0
ghost/.gitkeep Normal file
View file

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,62 @@
# Adapter Manager
A manager for retrieving custom "adapters" - can be used to abstract away from custom implementations
## Install
`npm install @tryghost/adapter-manager --save`
or
`yarn add @tryghost/adapter-manager`
## Usage
```js
const AdapterManager = require('@tryghost/adapter-manager');
const adapterManager = new AdapterManager({
pathsToAdapters: [
'/path/to/custom/adapters',
'/path/to/default/adapters'
]
});
class MailAdapterBase {
someMethod() {}
}
adapterManager.register('mail', MailAdapterBase);
const mailAdapterInstance = adapterManager.getAdapter('mail', 'direct', mailConfig);
mailAdapterInstance.someMethod();
```
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/AdapterManager');

View file

@ -0,0 +1,143 @@
const path = require('path');
const errors = require('@tryghost/errors');
/**
* @typedef { function(new: Adapter, object) } AdapterConstructor
*/
/**
* @typedef {object} Adapter
* @prop {string[]} requiredFns
*/
module.exports = class AdapterManager {
/**
* @param {object} config
* @param {string[]} config.pathsToAdapters The paths to check, e.g. ['content/adapters', 'core/server/adapters']
* @param {(path: string) => AdapterConstructor} config.loadAdapterFromPath A function to load adapters, e.g. global.require
*/
constructor({pathsToAdapters, loadAdapterFromPath}) {
/**
* @private
* @type {Object.<string, AdapterConstructor>}
*/
this.baseClasses = {};
/**
* @private
* @type {Object.<string, Object.<string, Adapter>>}
*/
this.instanceCache = {};
/**
* @private
* @type {string[]}
*/
this.pathsToAdapters = pathsToAdapters;
/**
* @private
* @type {(path: string) => AdapterConstructor}
*/
this.loadAdapterFromPath = loadAdapterFromPath;
}
/**
* Register an adapter type and the corresponding base class. Must be called before requesting adapters of that type
*
* @param {string} type The name for the type of adapter
* @param {AdapterConstructor} BaseClass The class from which all adapters of this type must extend
*/
registerAdapter(type, BaseClass) {
this.instanceCache[type] = {};
this.baseClasses[type] = BaseClass;
}
/**
* getAdapter
*
* @param {string} adapterType The type of adapter, e.g. "storage" or "scheduling"
* @param {string} adapterName The active adapter, e.g. "LocalFileStorage"
* @param {object} config The config the adapter should be instantiated with
*
* @returns {Adapter} The resolved and instantiated adapter
*/
getAdapter(adapterType, adapterName, config) {
if (!adapterType || !adapterName) {
throw new errors.IncorrectUsageError({
message: 'getAdapter must be called with a adapterType and a name.'
});
}
const adapterCache = this.instanceCache[adapterType];
if (!adapterCache) {
throw new errors.NotFoundError({
message: `Unknown adapter type ${adapterType}. Please register adapter.`
});
}
if (adapterCache[adapterName]) {
return adapterCache[adapterName];
}
/** @type AdapterConstructor */
let Adapter;
for (const pathToAdapters of this.pathsToAdapters) {
const pathToAdapter = path.join(pathToAdapters, adapterType, adapterName);
try {
Adapter = this.loadAdapterFromPath(pathToAdapter);
if (Adapter) {
break;
}
} catch (err) {
// Catch runtime errors
if (err.code !== 'MODULE_NOT_FOUND') {
throw new errors.IncorrectUsageError({err});
}
// Catch missing dependencies BUT NOT missing adapter
if (!err.message.includes(pathToAdapter)) {
throw new errors.IncorrectUsageError({
message: `You are missing dependencies in your adapter ${pathToAdapter}`,
err
});
}
}
}
if (!Adapter) {
throw new errors.IncorrectUsageError({
message: `Unable to find ${adapterType} adapter ${adapterName} in ${this.pathsToAdapters}.`
});
}
const adapter = new Adapter(config);
if (!(adapter instanceof this.baseClasses[adapterType])) {
if (Object.getPrototypeOf(Adapter).name !== this.baseClasses[adapterType].name) {
throw new errors.IncorrectUsageError({
message: `${adapterType} adapter ${adapterName} does not inherit from the base class.`
});
}
}
if (!adapter.requiredFns) {
throw new errors.IncorrectUsageError({
message: `${adapterType} adapter ${adapterName} does not have the requiredFns.`
});
}
for (const requiredFn of adapter.requiredFns) {
if (typeof adapter[requiredFn] !== 'function') {
throw new errors.IncorrectUsageError({
message: `${adapterType} adapter ${adapterName} is missing the ${requiredFn} method.`
});
}
}
adapterCache[adapterName] = adapter;
return adapter;
}
};

View file

@ -0,0 +1,30 @@
{
"name": "@tryghost/adapter-manager",
"version": "0.2.33",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/adapter-manager",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/errors": "^1.2.1"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,80 @@
const sinon = require('sinon');
const should = require('should');
const AdapterManager = require('../');
class BaseMailAdapter {
constructor() {
this.requiredFns = ['someMethod'];
}
}
class IncompleteMailAdapter extends BaseMailAdapter {}
class CustomMailAdapter extends BaseMailAdapter {
someMethod() {}
}
class DefaultMailAdapter extends BaseMailAdapter {
someMethod() {}
}
describe('AdapterManager', function () {
it('Loads registered adapters in the order defined by the paths', function () {
const pathsToAdapters = [
'first/path',
'second/path',
'third/path'
];
const loadAdapterFromPath = sinon.stub();
loadAdapterFromPath.withArgs('first/path/mail/incomplete')
.returns(IncompleteMailAdapter);
loadAdapterFromPath.withArgs('second/path/mail/custom')
.returns(CustomMailAdapter);
loadAdapterFromPath.withArgs('third/path/mail/default')
.returns(DefaultMailAdapter);
loadAdapterFromPath.withArgs('first/path/mail/broken')
.throwsException('SHIT_GOT_REAL');
const adapterManager = new AdapterManager({
loadAdapterFromPath,
pathsToAdapters
});
adapterManager.registerAdapter('mail', BaseMailAdapter);
try {
const customAdapter = adapterManager.getAdapter('mail', 'custom', {});
should.ok(customAdapter instanceof BaseMailAdapter);
should.ok(customAdapter instanceof CustomMailAdapter);
} catch (err) {
should.fail(err, null, 'Should not have errored');
}
try {
const incompleteAdapter = adapterManager.getAdapter('mail', 'incomplete', {});
should.fail(incompleteAdapter, null, 'Should not have created');
} catch (err) {
should.exist(err);
should.equal(err.errorType, 'IncorrectUsageError');
}
try {
const defaultAdapter = adapterManager.getAdapter('mail', 'default', {});
should.ok(defaultAdapter instanceof BaseMailAdapter);
should.ok(defaultAdapter instanceof DefaultMailAdapter);
} catch (err) {
should.fail(err, null, 'Should not have errored');
}
try {
const brokenAdapter = adapterManager.getAdapter('mail', 'broken', {});
should.fail(brokenAdapter, null, 'Should not have created');
} catch (err) {
should.exist(err);
should.equal(err.errorType, 'IncorrectUsageError');
}
});
});

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"allowJs": true,
"checkJs": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es6"
},
"exclude": [
"node_modules"
]
}

View file

@ -0,0 +1,2 @@
declare const _exports: typeof import("./lib/AdapterManager");
export = _exports;

View file

@ -0,0 +1,56 @@
export = AdapterManager;
declare class AdapterManager {
/**
* @param {object} config
* @param {string[]} config.pathsToAdapters The paths to check, e.g. ['content/adapters', 'core/server/adapters']
* @param {(path: string) => AdapterConstructor} config.loadAdapterFromPath A function to load adapters, e.g. global.require
*/
constructor({ pathsToAdapters, loadAdapterFromPath }: {
pathsToAdapters: string[];
loadAdapterFromPath: (path: string) => AdapterConstructor;
});
/**
* @private
* @type {Object.<string, AdapterConstructor>}
*/
private baseClasses;
/**
* @private
* @type {Object.<string, Object.<string, Adapter>>}
*/
private instanceCache;
/**
* @private
* @type {string[]}
*/
private pathsToAdapters;
/**
* @private
* @type {(path: string) => AdapterConstructor}
*/
private loadAdapterFromPath;
/**
* Register an adapter type and the corresponding base class. Must be called before requesting adapters of that type
*
* @param {string} type The name for the type of adapter
* @param {AdapterConstructor} BaseClass The class from which all adapters of this type must extend
*/
registerAdapter(type: string, BaseClass: AdapterConstructor): void;
/**
* getAdapter
*
* @param {string} adapterType The type of adapter, e.g. "storage" or "scheduling"
* @param {string} adapterName The active adapter, e.g. "LocalFileStorage"
* @param {object} config The config the adapter should be instantiated with
*
* @returns {Adapter} The resolved and instantiated adapter
*/
getAdapter(adapterType: string, adapterName: string, config: object): Adapter;
}
declare namespace AdapterManager {
export { AdapterConstructor, Adapter };
}
type AdapterConstructor = new (arg1: object) => Adapter;
type Adapter = {
requiredFns: string[];
};

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,41 @@
# Api Version Compatibility Service
Service to notify Ghost instance owneres about API version compatibility issues
## Install
`npm install @tryghost/api-version-compatibility-service --save`
or
`yarn add @tryghost/api-version-compatibility-service`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/api-version-compatibility-service');

View file

@ -0,0 +1,81 @@
const path = require('path');
const VersionNotificationsDataService = require('@tryghost/version-notifications-data-service');
const EmailContentGenerator = require('@tryghost/email-content-generator');
class APIVersionCompatibilityService {
/**
*
* @param {Object} options
* @param {Object} options.UserModel - ghost user model
* @param {Object} options.ApiKeyModel - ghost api key model
* @param {Object} options.settingsService - ghost settings service
* @param {(Object: {subject: String, to: String, text: String, html: String}) => Promise<any>} options.sendEmail - email sending function
* @param {Function} options.getSiteUrl
* @param {Function} options.getSiteTitle
*/
constructor({UserModel, ApiKeyModel, settingsService, sendEmail, getSiteUrl, getSiteTitle}) {
this.sendEmail = sendEmail;
this.versionNotificationsDataService = new VersionNotificationsDataService({
UserModel,
ApiKeyModel,
settingsService
});
this.emailContentGenerator = new EmailContentGenerator({
getSiteUrl,
getSiteTitle,
templatesDir: path.join(__dirname, 'templates')
});
}
/**
* Version mismatch handler doing the logic of picking a template and sending a notification email
* @param {Object} options
* @param {string} options.acceptVersion - client's accept-version header value
* @param {string} options.contentVersion - server's content-version header value
* @param {string} options.apiKeyValue - key value (secret for Content API and kid for Admin API) used to access the API
* @param {string} options.apiKeyType - key type used to access the API
* @param {string} options.requestURL - url that was requested and failed compatibility test
* @param {string} [options.userAgent] - client's user-agent header value
*/
async handleMismatch({acceptVersion, contentVersion, apiKeyValue, apiKeyType, requestURL, userAgent = ''}) {
if (!await this.versionNotificationsDataService.fetchNotification(acceptVersion)) {
const integrationName = await this.versionNotificationsDataService.getIntegrationName(apiKeyValue, apiKeyType);
const trimmedUseAgent = userAgent.split('/')[0];
const emails = await this.versionNotificationsDataService.getNotificationEmails();
for (const email of emails) {
const template = (trimmedUseAgent === 'Zapier')
? 'zapier-mismatch'
: 'generic-mismatch';
const subject = (trimmedUseAgent === 'Zapier')
? 'Attention required: One of your Zaps has failed'
: `Attention required: Your ${integrationName} integration has failed`;
const {html, text} = await this.emailContentGenerator.getContent({
template,
data: {
acceptVersion,
contentVersion,
clientName: integrationName,
recipientEmail: email,
requestURL: requestURL
}
});
await this.sendEmail({
subject,
to: email,
html,
text
});
}
await this.versionNotificationsDataService.saveNotification(acceptVersion);
}
}
}
module.exports = APIVersionCompatibilityService;

View file

@ -0,0 +1,168 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Integration error</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .title {
font-size: 22px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 12x !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.recipient-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
a {
color: #3A464C;
}
</style>
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED CONTAINER -->
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td align="center" style="padding-top: 20px; padding-bottom: 12px;"><img src="https://static.ghost.org/v4.0.0/images/ghost-orb-1.png" width="60" height="60" style="width: 60px; height: 60px;" /></td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
<p class="title" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 21px; color: #3A464C; font-weight: normal; line-height: 25px; margin-bottom: 0px; margin-top: 50px; font-weight: 600; color: #15212A;">Uh-oh!</p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 24px; padding-bottom: 10px;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 30px;">
Your <strong style="font-weight: 600;">{{clientName}}</strong> integration is no longer working as expected. This integration must be updated by its developer to work with your version of Ghost.
</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 30px;">
To help you get things fixed as quickly as possible, Ghost has automatically generated some helpful information about the error that you can share with the creator of the {{clientName}} integration below:
</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 20px;background-color:#f6f6f6; padding:20px; border-radius:2px;">
<strong style="font-weight: 600;">Integration expected Ghost version:</strong>&nbsp; {{acceptVersion}}
<br>
<strong style="font-weight: 600;">Current Ghost version:</strong>&nbsp; {{contentVersion}}
<br>
<strong style="font-weight: 600;">Failed request URL:</strong>&nbsp; {{requestURL}}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-top: 80px; padding-bottom: 10px;">
<div class="footer">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #738A94; font-weight: normal; margin: 0; line-height: 18px; margin-bottom: 0px; font-size: 11px;">This email was sent from <a href="{{siteUrl}}" style="color: #738A94;">{{siteUrl}}</a> to <a href="mailto:{{recipientEmail}}" style="color: #738A94;">{{recipientEmail}}</a></p>
</div>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,159 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Integration error</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .title {
font-size: 22px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 12x !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.recipient-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
a {
color: #3A464C;
}
</style>
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED CONTAINER -->
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td align="center" style="padding-top: 20px; padding-bottom: 12px;"><img src="https://static.ghost.org/v4.0.0/images/ghost-orb-1.png" width="60" height="60" style="width: 60px; height: 60px;" /></td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
<p class="title" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 21px; color: #3A464C; font-weight: normal; line-height: 25px; margin-bottom: 0px; margin-top: 50px; font-weight: 600; color: #15212A;">Uh-oh!</p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-top: 24px; padding-bottom: 10px;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 30px;">
One of the Zaps in your Zapier integration has <span style="font-weight: 600;">stopped working</span>.
</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 30px;">
To get this resolved as quickly as possible, please log in to your Zapier account to view any failing Zaps and recreate them using the most recent Ghost-supported versions. Zap errors can be found <a href="https://zapier.com/app/history/usage" style="color: #738A94;">here</a> in your “Zap history”.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-top: 80px; padding-bottom: 10px;">
<div class="footer">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #738A94; font-weight: normal; margin: 0; line-height: 18px; margin-bottom: 0px; font-size: 11px;">This email was sent from <a href="{{ siteUrl }}" style="color: #738A94;">{{ siteUrl }}</a> to <a href="mailto:{{recipientEmail}}" style="color: #738A94;">{{recipientEmail}}</a></p>
</div>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,32 @@
{
"name": "@tryghost/api-version-compatibility-service",
"version": "0.4.4",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/api-version-compatibility-service",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/email-content-generator": "^0.1.4",
"@tryghost/version-notifications-data-service": "^0.2.2"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,359 @@
const assert = require('assert');
const sinon = require('sinon');
const APIVersionCompatibilityService = require('../index');
describe('APIVersionCompatibilityService', function () {
const getSiteUrl = () => 'https://amazeballsghostsite.com';
const getSiteTitle = () => 'Tahini and chickpeas';
let UserModel;
let ApiKeyModel;
let settingsService;
beforeEach(function () {
UserModel = {
findAll: sinon
.stub()
.withArgs({
withRelated: ['roles'],
filter: 'status:active'
}, {
internal: true
})
.resolves({
toJSON: () => [{
email: 'simon@example.com',
roles: [{
name: 'Administrator'
}]
}]
})
};
ApiKeyModel = {
findOne: sinon
.stub()
.withArgs({
secret: 'super_secret'
}, {
withRelated: ['integration']
})
.resolves({
relations: {
integration: {
get: () => 'Elaborate Fox'
}
}
})
};
settingsService = {
read: sinon.stub().resolves({
version_notifications: {
value: JSON.stringify([
'v3.4',
'v4.1'
])
}
}),
edit: sinon.stub().resolves()
};
});
afterEach(function () {
sinon.reset();
});
it('Sends an email to the instance owners when fresh accept-version header mismatch detected', async function () {
const sendEmail = sinon.spy();
const compatibilityService = new APIVersionCompatibilityService({
UserModel,
ApiKeyModel,
settingsService,
sendEmail,
getSiteUrl,
getSiteTitle
});
await compatibilityService.handleMismatch({
acceptVersion: 'v4.5',
contentVersion: 'v5.1',
userAgent: 'GhostAdminSDK/2.4.0',
requestURL: '/ghost/api/admin/posts/dew023d9203se4',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.called, true);
assert.equal(sendEmail.args[0][0].to, 'simon@example.com');
assert.equal(sendEmail.args[0][0].subject, `Attention required: Your Elaborate Fox integration has failed`);
assert.match(sendEmail.args[0][0].html, /Your <strong style="font-weight: 600;">Elaborate Fox<\/strong> integration is no longer working as expected\./);
assert.match(sendEmail.args[0][0].html, /Integration expected Ghost version:<\/strong>&nbsp; v4.5/);
assert.match(sendEmail.args[0][0].html, /Current Ghost version:<\/strong>&nbsp; v5.1/);
assert.match(sendEmail.args[0][0].html, /Failed request URL:<\/strong>&nbsp; \/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].html, /This email was sent from <a href="https:\/\/amazeballsghostsite.com"/);
assert.match(sendEmail.args[0][0].html, /to <a href="mailto:simon@example.com"/);
assert.match(sendEmail.args[0][0].text, /Your Elaborate Fox integration is no longer working/);
assert.match(sendEmail.args[0][0].text, /Integration expected Ghost version:v4.5/);
assert.match(sendEmail.args[0][0].text, /Current Ghost version:v5.1/);
assert.match(sendEmail.args[0][0].text, /Failed request URL:/);
assert.match(sendEmail.args[0][0].text, /\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].text, /This email was sent from https:\/\/amazeballsghostsite.com/);
assert.match(sendEmail.args[0][0].text, /to simon@example.com/);
});
it('Does NOT send an email to the instance owner when previously handled accept-version header mismatch is detected', async function () {
const sendEmail = sinon.spy();
settingsService = {
read: sinon.stub()
.onFirstCall().resolves({
version_notifications: {
value: JSON.stringify([])
}
})
.onSecondCall().resolves({
version_notifications: {
value: JSON.stringify([])
}
})
.onThirdCall().resolves({
version_notifications: {
value: JSON.stringify([
'v4.5'
])
}
}),
edit: sinon.stub().resolves()
};
const compatibilityService = new APIVersionCompatibilityService({
sendEmail,
ApiKeyModel,
UserModel,
settingsService,
getSiteUrl,
getSiteTitle
});
await compatibilityService.handleMismatch({
acceptVersion: 'v4.5',
contentVersion: 'v5.1',
userAgent: 'GhostAdminSDK/2.4.0',
requestURL: 'https://amazeballsghostsite.com/ghost/api/admin/posts/dew023d9203se4',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.called, true);
assert.equal(sendEmail.args[0][0].to, 'simon@example.com');
assert.equal(sendEmail.args[0][0].subject, `Attention required: Your Elaborate Fox integration has failed`);
assert.match(sendEmail.args[0][0].html, /Your <strong style="font-weight: 600;">Elaborate Fox<\/strong> integration is no longer working as expected\./);
assert.match(sendEmail.args[0][0].html, /Integration expected Ghost version:<\/strong>&nbsp; v4.5/);
assert.match(sendEmail.args[0][0].html, /Current Ghost version:<\/strong>&nbsp; v5.1/);
assert.match(sendEmail.args[0][0].html, /Failed request URL:<\/strong>&nbsp; https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].html, /This email was sent from <a href="https:\/\/amazeballsghostsite.com"/);
assert.match(sendEmail.args[0][0].html, /to <a href="mailto:simon@example.com"/);
assert.match(sendEmail.args[0][0].text, /Your Elaborate Fox integration is no longer working/);
assert.match(sendEmail.args[0][0].text, /Integration expected Ghost version:v4.5/);
assert.match(sendEmail.args[0][0].text, /Current Ghost version:v5.1/);
assert.match(sendEmail.args[0][0].text, /Failed request URL:/);
assert.match(sendEmail.args[0][0].text, /https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].text, /This email was sent from https:\/\/amazeballsghostsite.com/);
assert.match(sendEmail.args[0][0].text, /to simon@example.com/);
await compatibilityService.handleMismatch({
acceptVersion: 'v4.5',
contentVersion: 'v5.1',
userAgent: 'GhostAdminSDK/2.4.0',
requestURL: 'does not matter',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.calledOnce, true);
assert.equal(sendEmail.calledTwice, false);
});
it('Does send multiple emails to the instance owners when previously unhandled accept-version header mismatch is detected', async function () {
const sendEmail = sinon.spy();
UserModel = {
findAll: sinon
.stub()
.withArgs({
withRelated: ['roles'],
filter: 'status:active'
}, {
internal: true
})
.resolves({
toJSON: () => [{
email: 'simon@example.com',
roles: [{
name: 'Administrator'
}]
}, {
email: 'sam@example.com',
roles: [{
name: 'Owner'
}]
}]
})
};
settingsService = {
read: sinon.stub()
.onCall(0).resolves({
version_notifications: {
value: JSON.stringify([])
}
})
.onCall(1).resolves({
version_notifications: {
value: JSON.stringify([])
}
})
.onCall(2).resolves({
version_notifications: {
value: JSON.stringify([
'v4.5'
])
}
})
.onCall(3).resolves({
version_notifications: {
value: JSON.stringify([
'v4.5'
])
}
}),
edit: sinon.stub().resolves()
};
const compatibilityService = new APIVersionCompatibilityService({
sendEmail,
UserModel,
ApiKeyModel,
settingsService,
getSiteUrl,
getSiteTitle
});
await compatibilityService.handleMismatch({
acceptVersion: 'v4.5',
contentVersion: 'v5.1',
userAgent: 'GhostAdminSDK/2.4.0',
requestURL: 'https://amazeballsghostsite.com/ghost/api/admin/posts/dew023d9203se4',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.calledTwice, true);
assert.equal(sendEmail.args[0][0].to, 'simon@example.com');
assert.equal(sendEmail.args[0][0].subject, `Attention required: Your Elaborate Fox integration has failed`);
assert.match(sendEmail.args[0][0].html, /Your <strong style="font-weight: 600;">Elaborate Fox<\/strong> integration is no longer working as expected\./);
assert.match(sendEmail.args[0][0].html, /Integration expected Ghost version:<\/strong>&nbsp; v4.5/);
assert.match(sendEmail.args[0][0].html, /Current Ghost version:<\/strong>&nbsp; v5.1/);
assert.match(sendEmail.args[0][0].html, /Failed request URL:<\/strong>&nbsp; https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].html, /This email was sent from <a href="https:\/\/amazeballsghostsite.com"/);
assert.match(sendEmail.args[0][0].html, /to <a href="mailto:simon@example.com"/);
assert.match(sendEmail.args[0][0].text, /Your Elaborate Fox integration is no longer working/);
assert.match(sendEmail.args[0][0].text, /Integration expected Ghost version:v4.5/);
assert.match(sendEmail.args[0][0].text, /Current Ghost version:v5.1/);
assert.match(sendEmail.args[0][0].text, /Failed request URL:/);
assert.match(sendEmail.args[0][0].text, /https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[0][0].text, /This email was sent from https:\/\/amazeballsghostsite.com/);
assert.match(sendEmail.args[0][0].text, /to simon@example.com/);
assert.equal(sendEmail.calledTwice, true);
assert.equal(sendEmail.args[1][0].to, 'sam@example.com');
assert.equal(sendEmail.args[1][0].subject, `Attention required: Your Elaborate Fox integration has failed`);
assert.match(sendEmail.args[1][0].html, /Your <strong style="font-weight: 600;">Elaborate Fox<\/strong> integration is no longer working as expected\./);
assert.match(sendEmail.args[1][0].html, /Integration expected Ghost version:<\/strong>&nbsp; v4.5/);
assert.match(sendEmail.args[1][0].html, /Current Ghost version:<\/strong>&nbsp; v5.1/);
assert.match(sendEmail.args[1][0].html, /Failed request URL:<\/strong>&nbsp; https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[1][0].html, /This email was sent from <a href="https:\/\/amazeballsghostsite.com"/);
assert.match(sendEmail.args[1][0].html, /to <a href="mailto:sam@example.com"/);
assert.match(sendEmail.args[1][0].text, /Your Elaborate Fox integration is no longer working/);
assert.match(sendEmail.args[1][0].text, /Integration expected Ghost version:v4.5/);
assert.match(sendEmail.args[1][0].text, /Current Ghost version:v5.1/);
assert.match(sendEmail.args[1][0].text, /Failed request URL:/);
assert.match(sendEmail.args[1][0].text, /https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[1][0].text, /This email was sent from https:\/\/amazeballsghostsite.com/);
assert.match(sendEmail.args[1][0].text, /to sam@example.com/);
await compatibilityService.handleMismatch({
acceptVersion: 'v4.8',
contentVersion: 'v5.1',
userAgent: 'GhostAdminSDK/2.4.0',
requestURL: 'https://amazeballsghostsite.com/ghost/api/admin/posts/dew023d9203se4',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.callCount, 4);
assert.equal(sendEmail.args[2][0].to, 'simon@example.com');
assert.match(sendEmail.args[2][0].html, /Your <strong style="font-weight: 600;">Elaborate Fox<\/strong> integration is no longer working as expected\./);
assert.match(sendEmail.args[2][0].html, /Integration expected Ghost version:<\/strong>&nbsp; v4.8/);
assert.match(sendEmail.args[2][0].html, /Current Ghost version:<\/strong>&nbsp; v5.1/);
assert.match(sendEmail.args[2][0].html, /Failed request URL:<\/strong>&nbsp; https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
assert.match(sendEmail.args[2][0].text, /Your Elaborate Fox integration is no longer working/);
assert.match(sendEmail.args[2][0].text, /Integration expected Ghost version:v4.8/);
assert.match(sendEmail.args[2][0].text, /Current Ghost version:v5.1/);
assert.match(sendEmail.args[2][0].text, /Failed request URL:/);
assert.match(sendEmail.args[2][0].text, /https:\/\/amazeballsghostsite.com\/ghost\/api\/admin\/posts\/dew023d9203se4/);
});
it('Sends Zapier-specific email when userAgent is a Zapier client', async function (){
const sendEmail = sinon.spy();
const compatibilityService = new APIVersionCompatibilityService({
sendEmail,
UserModel,
ApiKeyModel,
settingsService,
getSiteUrl,
getSiteTitle
});
await compatibilityService.handleMismatch({
acceptVersion: 'v4.5',
contentVersion: 'v5.1',
userAgent: 'Zapier/4.20 GhostAdminSDK/2.4.0',
requestURL: 'https://amazeballsghostsite.com/ghost/api/admin/posts/dew023d9203se4',
apiKeyValue: 'secret',
apiKeyType: 'content'
});
assert.equal(sendEmail.called, true);
assert.equal(sendEmail.args[0][0].to, 'simon@example.com');
assert.equal(sendEmail.args[0][0].subject, `Attention required: One of your Zaps has failed`);
assert.match(sendEmail.args[0][0].html, /One of the Zaps in your Zapier integration has <span style="font-weight: 600;">stopped working<\/span>\./);
assert.match(sendEmail.args[0][0].html, /To get this resolved as quickly as possible, please log in to your Zapier account to view any failing Zaps and recreate them using the most recent Ghost-supported versions. Zap errors can be found <a href="https:\/\/zapier.com\/app\/history\/usage" style="color: #738A94;">here<\/a> in your “Zap history”\./);
assert.match(sendEmail.args[0][0].html, /This email was sent from <a href="https:\/\/amazeballsghostsite.com"/);
assert.match(sendEmail.args[0][0].html, /to <a href="mailto:simon@example.com"/);
assert.match(sendEmail.args[0][0].text, /One of the Zaps in your Zapier integration has stopped/);
assert.match(sendEmail.args[0][0].text, /To get this resolved as quickly as possible, please log in to your Zapier/);
assert.match(sendEmail.args[0][0].text, /This email was sent from https:\/\/amazeballsghostsite.com/);
assert.match(sendEmail.args[0][0].text, /to simon@example.com/);
});
});

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,39 @@
# Bootstrap Socket
## Install
`npm install @tryghost/bootstrap-socket --save`
or
`yarn add @tryghost/bootstrap-socket`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/bootstrap-socket');

View file

@ -0,0 +1,87 @@
const logging = require('@tryghost/logging');
module.exports.connectAndSend = (socketAddress, message) => {
// Very basic guard against bad calls
if (!socketAddress || !socketAddress.host || !socketAddress.port || !logging || !logging.info || !logging.warn || !message) {
return Promise.resolve();
}
const net = require('net');
const client = new net.Socket();
return new Promise((resolve) => {
const connect = (options = {}) => {
let wasResolved = false;
const waitTimeout = setTimeout(() => {
logging.info('Bootstrap socket timed out.');
if (!client.destroyed) {
client.destroy();
}
if (wasResolved) {
return;
}
wasResolved = true;
resolve();
}, 1000 * 5);
client.connect(socketAddress.port, socketAddress.host, () => {
if (waitTimeout) {
clearTimeout(waitTimeout);
}
client.write(JSON.stringify(message));
if (wasResolved) {
return;
}
wasResolved = true;
resolve();
});
client.on('close', () => {
logging.info('Bootstrap client was closed.');
if (waitTimeout) {
clearTimeout(waitTimeout);
}
});
client.on('error', (err) => {
logging.warn(`Can't connect to the bootstrap socket (${socketAddress.host} ${socketAddress.port}) ${err.code}.`);
client.removeAllListeners();
if (waitTimeout) {
clearTimeout(waitTimeout);
}
if (options.tries < 3) {
logging.warn(`Tries: ${options.tries}`);
// retry
logging.warn('Retrying...');
options.tries = options.tries + 1;
const retryTimeout = setTimeout(() => {
clearTimeout(retryTimeout);
connect(options);
}, 150);
} else {
if (wasResolved) {
return;
}
wasResolved = true;
resolve();
}
});
};
connect({tries: 0});
});
};

View file

@ -0,0 +1,30 @@
{
"name": "@tryghost/bootstrap-socket",
"version": "0.2.22",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/bootstrap-socket",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/logging": "^2.0.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,11 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const bootstrapSocket = require('../lib/bootstrap-socket');
describe('Connect and send', function () {
it('Resolves a promise for a bad call', function () {
bootstrapSocket.connectAndSend().should.be.fulfilled();
});
});

View file

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View file

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View file

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

21
ghost/constants/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

39
ghost/constants/README.md Normal file
View file

@ -0,0 +1,39 @@
# Constants
## Install
`npm install @tryghost/constants --save`
or
`yarn add @tryghost/constants`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

17
ghost/constants/index.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
ONE_HOUR_S: 3600,
ONE_DAY_S: 86400,
ONE_MONTH_S: 2628000,
SIX_MONTH_S: 15768000,
ONE_YEAR_S: 31536000,
FIVE_MINUTES_MS: 300000,
ONE_HOUR_MS: 3600000,
ONE_DAY_MS: 86400000,
ONE_WEEK_MS: 604800000,
ONE_MONTH_MS: 2628000000,
SIX_MONTH_MS: 15768000000,
ONE_YEAR_MS: 31536000000,
STATIC_IMAGES_URL_PREFIX: 'content/images',
STATIC_MEDIA_URL_PREFIX: 'content/media',
STATIC_FILES_URL_PREFIX: 'content/files'
};

View file

@ -0,0 +1,27 @@
{
"name": "@tryghost/constants",
"version": "1.0.7",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/constants",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,10 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
'hello'.should.eql('hello');
});
});

View file

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View file

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View file

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,41 @@
# Email Content Generator
Utility to generate html and text versions for emails using email templates and data
## Install
`npm install @tryghost/email-content-generator --save`
or
`yarn add @tryghost/email-content-generator`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/email-content-generator');

View file

@ -0,0 +1,54 @@
const _ = require('lodash').runInContext();
const fs = require('fs-extra');
const path = require('path');
const htmlToText = require('html-to-text');
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;
class EmailContentGenerator {
/**
*
* @param {Object} options
* @param {function} options.getSiteUrl
* @param {function} options.getSiteTitle
* @param {string} options.templatesDir - path to the directory containing email templates
*/
constructor({getSiteUrl, getSiteTitle, templatesDir}) {
this.getSiteUrl = getSiteUrl;
this.getSiteTitle = getSiteTitle;
this.templatesDir = templatesDir;
}
/**
*
* @param {Object} options
* @param {string} options.template - HTML template name to use for generation
* @param {Object} [options.data] - variable data to use during HTML template compilation
* @returns {Promise<{html: String, text: String}>} resolves with an object containing html and text properties
*/
async getContent(options) {
const defaults = {
siteUrl: this.getSiteUrl(),
siteTitle: this.getSiteTitle()
};
const data = _.defaults(defaults, options.data);
// read the proper email body template
const content = await fs.readFile(path.join(this.templatesDir, options.template + '.html'), 'utf8');
// insert user-specific data into the email
const compiled = _.template(content);
const htmlContent = compiled(data);
// generate a plain-text version of the same email
const textContent = htmlToText.fromString(htmlContent);
return {
html: htmlContent,
text: textContent
};
}
}
module.exports = EmailContentGenerator;

View file

@ -0,0 +1,32 @@
{
"name": "@tryghost/email-content-generator",
"version": "0.1.4",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/email-content-generator",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"html-to-text": "^5.1.1"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,102 @@
const assert = require('assert');
const path = require('path');
const EmailContentGenerator = require('../index');
describe('Mail: EmailContentGenerator', function () {
it('generate welcome', async function () {
const emailContentGenerator = new EmailContentGenerator({
getSiteTitle: () => 'The Ghost Blog',
getSiteUrl: () => 'http://myblog.com',
templatesDir: path.resolve(__dirname, './fixtures/templates/')
});
const content = await emailContentGenerator.getContent({
template: 'welcome',
data: {
ownerEmail: 'test@example.com'
}
});
assert.match(content.html, /<title>Welcome to Ghost<\/title>/);
assert.match(content.html, /This email was sent from <a href="http:\/\/myblog.com" style="color: #738A94;">http:\/\/myblog.com<\/a> to <a href="mailto:test@example.com" style="color: #738A94;">test@example.com<\/a><\/p>/);
assert.match(content.text, /Email Address: test@example.com \[test@example.com\]/);
assert.match(content.text, /This email was sent from http:\/\/myblog.com/);
});
it('generates newsletter template', async function () {
const emailContentGenerator = new EmailContentGenerator({
getSiteTitle: () => 'The Ghost Blog',
getSiteUrl: () => 'http://myblog.com',
templatesDir: path.resolve(__dirname, './fixtures/templates/')
});
const content = await emailContentGenerator.getContent({
template: 'newsletter',
data: {
blog: {
logo: 'http://myblog.com/content/images/blog-logo.jpg',
title: 'The Ghost Blog',
url: 'http://myblog.com',
twitter: 'http://twitter.com/ghost',
facebook: 'https://www.facebook.com/ghost',
unsubscribe: 'http://myblog.com/unsubscribe',
post: [
{
picture: 'http://myblog.com/content/images/post-1-image.jpg',
title: 'Featured blog post',
text: 'This is a featured blog post. It&#x2019;s awesome&#x2026;',
url: 'http://myblog.com/featured-blog-post',
tag: 'featured',
author: 'harry potter'
},
{
picture: 'http://myblog.com/content/images/post-2-image.jpg',
title: 'Second blog post',
text: 'This is the second blog post. It&#x2019;s also awesome&#x2026;',
url: 'http://myblog.com/second-blog-post',
tag: 'second',
author: 'lord voldemord'
},
{
picture: 'http://myblog.com/content/images/post-3-image.jpg',
title: 'Third blog post',
text: 'This is the third blog post. It&#x2019;s also awesome&#x2026;',
url: 'http://myblog.com/third-blog-post',
tag: 'third',
author: 'marry poppins'
},
{
picture: 'http://myblog.com/content/images/post-4-image.jpg',
title: 'Fourth blog post',
text: 'This is the fourth blog post. It&#x2019;s also awesome&#x2026;',
url: 'http://myblog.com/fourth-blog-post',
tag: 'fourth',
author: 'donald duck'
},
{
picture: 'http://myblog.com/content/images/post-5-image.jpg',
title: 'Fifth blog post',
text: 'This is the fifth blog post. It&#x2019;s also awesome&#x2026;',
url: 'http://myblog.com/fifth-blog-post',
tag: 'fifth',
author: 'casper the ghost'
}
]
},
newsletter: {
interval: 'monthly',
date: 'june, 9th 2016'
}
}
});
assert.match(content.html, /<title>The Ghost Blog<\/title>/);
assert.match(content.html, /<span style="text-transform:capitalize">monthly<\/span> digest/);
assert.match(content.html, /<span style="text-transform:capitalize">june, 9th 2016<\/span><\/h3>/);
assert.match(content.text, /MONTHLY DIGEST — JUNE, 9TH 2016/);
assert.match(content.text, /SECOND BLOG POST \[HTTP:\/\/MYBLOG.COM\/SECOND-BLOG-POST\]/);
});
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,161 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Welcome to Ghost</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .title {
font-size: 22px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 12x !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.recipient-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
a {
color: #3A464C;
}
</style>
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
<!-- START CENTERED CONTAINER -->
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td align="center" style="padding-top: 20px; padding-bottom: 12px;"><img src="https://static.ghost.org/v4.0.0/images/ghost-orb-3.png" width="60" height="60" style="width: 60px; height: 60px;" /></td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
<p class="title" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 21px; color: #3A464C; font-weight: normal; line-height: 25px; margin-bottom: 0px; margin-top: 50px; font-weight: 600; color: #15212A;">Hello!</p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 24px; padding-bottom: 10px;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 20px;">Good news! You've successfully created a brand new Ghost publication over on <a href="{{siteUrl}}" style="color: #15212A;">{{ siteUrl }}</a></p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 20px;">You can log in to your admin account with the following details:</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;"><strong style="font-weight: 600;">Email Address:</strong> <a href="mailto:{{ownerEmail}}" style="color: #15212A;">{{ownerEmail}}</a></p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 20px;"><strong style="font-weight: 600;">Password:</strong> The password you chose when you signed up</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">Keep this email somewhere safe for future reference, and have fun!</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 20px;">xoxo</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">Team Ghost <a href="https://ghost.org" style="color: #15212A;">https://ghost.org</a></p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-top: 80px; padding-bottom: 10px;">
<div class="footer">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #738A94; font-weight: normal; margin: 0; line-height: 18px; margin-bottom: 0px; font-size: 11px;">This email was sent from <a href="{{ siteUrl }}" style="color: #738A94;">{{ siteUrl }}</a> to <a href="mailto:{{ownerEmail}}" style="color: #738A94;">{{ownerEmail}}</a></p>
</div>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,41 @@
# Extract Api Key
Extracts API key value from requests in formats known to Ghost
## Install
`npm install @tryghost/extract-api-key --save`
or
`yarn add @tryghost/extract-api-key`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/extract-api-key');

View file

@ -0,0 +1,57 @@
const jwt = require('jsonwebtoken');
/**
* Remove 'Ghost' from raw authorization header and extract the JWT token.
* Eg. Authorization: Ghost ${JWT}
* @param {string} header
*/
const extractTokenFromHeader = (header) => {
const [scheme, token] = header.split(' ');
if (/^Ghost$/i.test(scheme)) {
return token;
}
};
const extractAdminAPIKey = (token) => {
const decoded = jwt.decode(token, {complete: true});
if (!decoded || !decoded.header || !decoded.header.kid) {
return null;
}
return decoded.header.kid;
};
/**
* @typedef {object} ApiKey
* @prop {string} key
* @prop {string} type
*/
/**
* When it's a Content API the function resolves with the value of the key secret.
* When it's an Admin API the function resolves with the value of the key id.
*
* @param {import('express').Request} req
* @returns {ApiKey}
*/
const extractAPIKey = (req) => {
let keyValue = null;
let keyType = null;
if (req.query && req.query.key) {
keyValue = req.query.key;
keyType = 'content';
} else if (req.headers && req.headers.authorization) {
keyValue = extractAdminAPIKey(extractTokenFromHeader(req.headers.authorization));
keyType = 'admin';
}
return {
key: keyValue,
type: keyType
};
};
module.exports = extractAPIKey;

View file

@ -0,0 +1,26 @@
{
"name": "@tryghost/extract-api-key",
"version": "0.1.0",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/extract-api-key",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"jsonwebtoken": "^8.5.1"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,48 @@
const assert = require('assert');
const extractApiKey = require('../index');
describe('Extract API Key', function () {
it('Returns nulls for a request without any key', function () {
const {key, type} = extractApiKey({
query: {
filter: 'status:active'
}
});
assert.equal(key, null);
assert.equal(type, null);
});
it('Extracts Content API key from the request', function () {
const {key, type} = extractApiKey({
query: {
key: '123thekey'
}
});
assert.equal(key, '123thekey');
assert.equal(type, 'content');
});
it('Extracts Admin API key from the request', function () {
const {key, type} = extractApiKey({
headers: {
authorization: 'Ghost eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjYyNzM4MjQzNDZiZjUxZjNhYWI5OTA5OSJ9.eyJpYXQiOjE2NTIxNjUyNDQsImV4cCI6MTY1MjE2NTU0NCwiYXVkIjoiL3YyL2FkbWluLyJ9.VdPOZ4XffgYd8qn_46zlJR3jW_rPZTw70COkG5IYIuU'
}
});
assert.equal(key, '6273824346bf51f3aab99099');
assert.equal(type, 'admin');
});
it('Returns null if malformatted Admin API Key', function () {
const {key, type} = extractApiKey({
headers: {
authorization: 'Ghost incorrectformat'
}
});
assert.equal(key, null);
assert.equal(type, 'admin');
});
});

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

21
ghost/job-manager/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

136
ghost/job-manager/README.md Normal file
View file

@ -0,0 +1,136 @@
# Job Manager
A manager for jobs (aka tasks) that have to be performed asynchronously, optionally recurring, scheduled or one-off in their nature. The job queue is manage in memory without additional dependencies.
## Install
`npm install @tryghost/job-manager --save`
or
`yarn add @tryghost/job-manager`
## Usage
Below is a sample code to wire up job manger and initialize jobs:
```js
const JobManager = require('@tryghost/job-manager');
const logging = {
info: console.log,
warn: console.log,
error: console.error
};
const jobManager = new JobManager(logging);
// register a job "function" with queued execution in current event loop
jobManager.addJob({
job: printWord(word) => console.log(word),
name: 'hello',
offloaded: false
});
// register a job "module" with queued execution in current even loop
jobManager.addJob({
job:'./path/to/email-module.js',
data: {email: 'send@here.com'},
offloaded: false
});
// register recurring job which needs execution outside of current event loop
jobManager.addJob({
at: 'every 5 minutes',
job: './path/to/jobs/check-emails.js',
name: 'email-checker'
});
// register recurring job with cron syntax running every 5 minutes
// job needs execution outside of current event loop
// for cron builder check https://crontab.guru/ (first value is seconds)
jobManager.addJob({
at: '0 1/5 * * * *',
job: './path/to/jobs/check-emails.js',
name: 'email-checker-cron'
});
// register a job to un immediately running outside of current even loop
jobManager.addJob({
job: './path/to/jobs/check-emails.js',
name: 'email-checker-now'
});
```
For more examples of JobManager initialization check [test/examples](https://github.com/TryGhost/Utils/tree/master/packages/job-manager/test/examples) directory.
### Job types and definitions
There are two types of jobs distinguished based on purpose and environment they run in:
- **"inline"** - job which is run in the same even loop as the caller. Should be used in situations when there is no even loop blocking operations and no need to manage memory leaks in sandboxed way. Sometimes
- **"offloaded"** - job which is executed in separate to caller's event loop. For Node >v12 clients it spawns a [Worker thread](https://nodejs.org/dist/latest-v12.x/docs/api/worker_threads.html#worker_threads_new_worker_filename_options), for older Node runtimes it is executed in separate process through [child_process](https://nodejs.org/docs/latest-v10.x/api/child_process.html). Comparing to **inline** jobs, **offloaded** jobs are safer to execute as they are run on a dedicated thread (or process) acting like a sandbox. These jobs also give better utilization of multi-core CPUs. This type of jobs is useful when there are heavy computations needed to be done blocking the event loop or need a sandboxed environment to run in safely. Example jobs would be: statistical information processing, memory intensive computations (e.g. recursive algorithms), processing that requires blocking I/O operations etc.
Job manager's instance registers jobs through `addJob` method. The `offloaded` parameter controls if the job is **inline** (executed in the same event loop) or is **offloaded** (executed in worker thread/separate process). By default `offloaded` is set to `true` - creates an "offloaded" job.
When `offloaded: false` parameter is passed into `addJob` method, job manager registers an **inline** function for execution in FIFO queue. The job should not be computationally intensive and should have small amount of asynchronous operations. The developer should always account that the function will be executed on the **same event loop, thread and process as caller's process**. **inline** jobs should be [JavaScript functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) or a path to a module that exports a function as default. Note, at the moment it's not possible to defined scheduled or recurring **inline** job.
When skipped or `offloaded: true` parameter is passed into `addJob` method, job manager registers execution of an **offloaded** job. The job can be scheduled to run immediately, in the future, or in recurring manner (through `at` parameter). Jobs created this way are managed by [bree](https://github.com/breejs/bree) job scheduling library. For examples of job scripts check out [this section](https://github.com/breejs/bree#nodejs-email-queue-job-scheduling-example) of bree's documentation, test [job examples](https://github.com/TryGhost/Utils/tree/master/packages/job-manager/test/jobs).
### Offloaded jobs rules of thumb
To prevent complications around failed job retries and and handling of specific job states here are some rules that should be followed for all scheduled jobs:
1. Jobs are **self contained** - meaning job manager should be able to run the job with the state information included within the job's parameters. Job script should look up for the rest of needed information from somewhere else, like a database, API, or file.
2. Jobs should be [idempotent](https://en.wikipedia.org/wiki/Idempotence) - consequent job executions should be safe.
3. Job **parameters** should be **kept to the minimum**. When passing large amounts of data around performance can suffer from slow JSON serialization. Also, storage size restrictions that can arise if there is a need to store parameters in the future.Job parameters should be kept to only information that is needed to retrieve the rest of information from somewhere else. For example, it's recommended to pass in only an *id* of the resource that could be fetched from the data storage during job execution or pass in a file path which could be read during execution.
4. Scheduled **job execution time should not overlap**. It's up to the registering service to assure job execution time does not ecceed time between subsequent scheduled jobs. For example, if job is scheduled to run every 5 minutes it should always run under 5 minutes, otherwise next scheduled job would fail to start.
### Offloaded jobs lifecycle
Offloaded jobs are running on dedicated worker threads which makes their lifecycle a bit different to inline jobs:
1. When **starting** a job it's only sharing ENV variables with it's parent process. The job itself is run on an independent JavaScript execution thread. The script has to re-initialize any modules it will use. For example it should take care of: model layer initialization, cache initialization, etc.
2. When **finishing** work in a job prefer to signal successful termination by sending 'done' message to the parent thread: `parentPort.postMessage('done')` ([example use](https://github.com/TryGhost/Utils/blob/0e423f6c5c69b08d81d470f49de95654d8cc90e3/packages/job-manager/test/jobs/graceful.js#L33-L37)). Finishing work this way terminates the thread through [worker.terminate()]((https://nodejs.org/dist/latest-v14.x/docs/api/worker_threads.html#worker_threads_worker_terminate)), which logs termination in parent process and flushes any pipes opened in thread.
3. Jobs that have iterative nature, or need cleanup before interrupting work should allow for **graceful shutdown** by listening on `'cancel'` message coming from parent thread ([example use](https://github.com/TryGhost/Utils/blob/0e423f6c5c69b08d81d470f49de95654d8cc90e3/packages/job-manager/test/jobs/graceful.js#L12-L16)).
4. When **exceptions** happen and expected outcome is to terminate current job, leave the exception unhandled allowing it to bubble up to the job manager. Unhandled exceptions [terminate current thread](https://nodejs.org/dist/latest-v14.x/docs/api/worker_threads.html#worker_threads_event_error) and allow for next scheduled job execution to happen.
For more nuances on job structure best practices check [bree documentation](https://github.com/breejs/bree#writing-jobs-with-promises-and-async-await).
### Offloaded job script quirks
⚠️ to ensure worker thread back compatibility and correct inter-thread communication use [btrheads](https://github.com/chjj/bthreads) polyfill instead of native [worker_threads](https://nodejs.org/api/worker_threads.html#worker_threads) module in job scripts.
Instead of:
```js
const {isMainThread, parentPort} = require('worker_threads');
```
use
```js
const {isMainThread, parentPort} = require('bthreads');
```
It should be possible to use native `worker_threads` module once Node v10 [hits EOL](https://nodejs.org/en/about/releases/) (2021-04-30).
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1 @@
module.exports = require('./lib/job-manager');

View file

@ -0,0 +1,43 @@
const isCronExpression = require('./is-cron-expression');
/**
* Creates job Object compatible with bree job definition (https://github.com/breejs/bree#job-options)
*
* @param {String | Date} [at] - Date, cron or human readable schedule format
* @param {Function|String} job - function or path to a module defining a job
* @param {Object} [data] - data to be passed into the job
* @param {String} [name] - job name
*/
const assemble = (at, job, data, name) => {
const breeJob = {
name: name,
// NOTE: both function and path syntaxes work with 'path' parameter
path: job
};
if (data) {
Object.assign(breeJob, {
worker: {
workerData: data
}
});
}
if (at instanceof Date) {
Object.assign(breeJob, {
date: at
});
} else if (at && isCronExpression(at)) {
Object.assign(breeJob, {
cron: at
});
} else if (at !== undefined) {
Object.assign(breeJob, {
interval: at
});
}
return breeJob;
};
module.exports = assemble;

View file

@ -0,0 +1,36 @@
const cronValidate = require('cron-validate');
/**
* Checks if expression follows supported crontab format
* reference: https://www.adminschoice.com/crontab-quick-reference
* builder: https://crontab.guru/
*
* e.g.:
* "2 * * * *" where:
*
* "* * * * * *"
*
* |
* day of week (0 - 7) (0 or 7 is Sun)
* month (1 - 12)
* day of month (1 - 31)
* hour (0 - 23)
* minute (0 - 59)
* second (0 - 59, optional)
*
* @param {String} expression in crontab format (https://www.gnu.org/software/mcron/manual/html_node/Crontab-file.html)
*
* @returns {boolean} wheather or not the expression is valid
*/
const isCronExpression = (expression) => {
let cronResult = cronValidate(expression, {
preset: 'default', // second field not supported in default preset
override: {
useSeconds: true // override preset option
}
});
return cronResult.isValid();
};
module.exports = isCronExpression;

View file

@ -0,0 +1,236 @@
const path = require('path');
const fastq = require('fastq');
const later = require('@breejs/later');
const Bree = require('bree');
const pWaitFor = require('p-wait-for');
const {UnhandledJobError, IncorrectUsageError} = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const isCronExpression = require('./is-cron-expression');
const assembleBreeJob = require('./assemble-bree-job');
const JobsRepository = require('./jobs-repository');
const worker = async (task, callback) => {
try {
let result = await task();
callback(null, result);
} catch (error) {
callback(error);
}
};
const handler = (error, result) => {
if (error) {
// TODO: this handler should not be throwing as this blocks the queue
// throw error;
}
// Can potentially standardise the result here
return result;
};
class JobManager {
/**
* @param {Object} options
* @param {Function} [options.errorHandler] - custom job error handler
* @param {Function} [options.workerMessageHandler] - custom message handler coming from workers
* @param {Object} [options.JobModel] - a model which can persist job data in the storage
*/
constructor({errorHandler, workerMessageHandler, JobModel}) {
this.queue = fastq(this, worker, 1);
this._jobMessageHandler = this._jobMessageHandler.bind(this);
this._jobErrorHandler = this._jobErrorHandler.bind(this);
const combinedMessageHandler = workerMessageHandler
? ({name, message}) => {
workerMessageHandler({name, message});
this._jobMessageHandler({name, message});
}
: this._jobMessageHandler;
const combinedErrorHandler = errorHandler
? (error, workerMeta) => {
errorHandler(error, workerMeta);
this._jobErrorHandler(error, workerMeta);
}
: this._jobErrorHandler;
this.bree = new Bree({
root: false, // set this to `false` to prevent requiring a root directory of jobs
hasSeconds: true, // precision is needed to avoid task overlaps after immediate execution
outputWorkerMetadata: true,
logger: logging,
errorHandler: combinedErrorHandler,
workerMessageHandler: combinedMessageHandler
});
this.bree.on('worker created', (name) => {
this._jobMessageHandler({name, message: 'started'});
});
this._jobsRepository = new JobsRepository({JobModel});
}
async _jobMessageHandler({name, message}) {
if (message === 'started') {
const job = await this._jobsRepository.read(name);
if (job) {
await this._jobsRepository.update(job.id, {
status: 'started',
started_at: new Date()
});
}
} else if (message === 'done') {
const job = await this._jobsRepository.read(name);
if (job) {
await this._jobsRepository.update(job.id, {
status: 'finished',
finished_at: new Date()
});
}
}
}
async _jobErrorHandler(error, workerMeta) {
const job = await this._jobsRepository.read(workerMeta.name);
if (job) {
await this._jobsRepository.update(job.id, {
status: 'failed'
});
}
}
/**
* By default schedules an "offloaded" job. If `offloaded: true` parameter is set,
* puts an "inline" immediate job into the queue.
*
* @param {Object} GhostJob - job options
* @prop {Function | String} GhostJob.job - function or path to a module defining a job
* @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
* @prop {String | Date} [GhostJob.at] - Date, cron or human readable schedule format. Manage will do immediate execution if not specified. Not supported for "inline" jobs
* @prop {Object} [GhostJob.data] - data to be passed into the job
* @prop {Boolean} [GhostJob.offloaded] - creates an "offloaded" job running in a worker thread by default. If set to "false" runs an "inline" job on the same event loop
*/
addJob({name, at, job, data, offloaded = true}) {
if (offloaded) {
logging.info('Adding offloaded job to the queue');
let schedule;
if (!name) {
if (typeof job === 'string') {
name = path.parse(job).name;
} else {
throw new IncorrectUsageError({
message: 'Name parameter should be present if job is a function'
});
}
}
if (at && !(at instanceof Date)) {
if (isCronExpression(at)) {
schedule = later.parse.cron(at, true);
} else {
schedule = later.parse.text(at);
}
if ((schedule.error && schedule.error !== -1) || schedule.schedules.length === 0) {
throw new IncorrectUsageError({
message: 'Invalid schedule format'
});
}
logging.info(`Scheduling job ${name} at ${at}. Next run on: ${later.schedule(schedule).next()}`);
} else if (at !== undefined) {
logging.info(`Scheduling job ${name} at ${at}`);
} else {
logging.info(`Scheduling job ${name} to run immediately`);
}
const breeJob = assembleBreeJob(at, job, data, name);
this.bree.add(breeJob);
return this.bree.start(name);
} else {
logging.info('Adding one off inline job to the queue');
this.queue.push(async () => {
try {
if (typeof job === 'function') {
await job(data);
} else {
await require(job)(data);
}
} catch (err) {
// NOTE: each job should be written in a safe way and handle all errors internally
// if the error is caught here jobs implementaton should be changed
logging.error(new UnhandledJobError({
context: (typeof job === 'function') ? 'function' : job,
err
}));
throw err;
}
}, handler);
}
}
/**
* Adds a job that could ever be executed once.
*
* @param {Object} GhostJob - job options
* @prop {Function | String} GhostJob.job - function or path to a module defining a job
* @prop {String} [GhostJob.name] - unique job name, if not provided takes function name or job script filename
* @prop {String | Date} [GhostJob.at] - Date, cron or human readable schedule format. Manage will do immediate execution if not specified. Not supported for "inline" jobs
* @prop {Object} [GhostJob.data] - data to be passed into the job
* @prop {Boolean} [GhostJob.offloaded] - creates an "offloaded" job running in a worker thread by default. If set to "false" runs an "inline" job on the same event loop
*/
async addOneOffJob({name, job, data, offloaded = true}) {
const persistedJob = await this._jobsRepository.read(name);
if (persistedJob) {
throw new IncorrectUsageError({
message: `A "${name}" one off job has already been executed.`
});
}
await this._jobsRepository.add({
name,
status: 'queued'
});
this.addJob({name, job, data, offloaded});
}
/**
* Removes an "offloaded" job from scheduled jobs queue.
* It's NOT yet possible to remove "inline" jobs (will be possible when scheduling is added https://github.com/breejs/bree/issues/68).
* The method will throw an Error if job with provided name does not exist.
*
* NOTE: current implementation does not guarante running job termination
* for details see https://github.com/breejs/bree/pull/64
*
* @param {String} name - job name
*/
async removeJob(name) {
await this.bree.remove(name);
}
/**
* @param {import('p-wait-for').Options} [options]
*/
async shutdown(options) {
await this.bree.stop();
if (this.queue.idle()) {
return;
}
logging.warn('Waiting for busy job queue');
await pWaitFor(() => this.queue.idle() === true, options);
logging.warn('Job queue finished');
}
}
module.exports = JobManager;

View file

@ -0,0 +1,23 @@
class JobsRepository {
constructor({JobModel}) {
this._JobModel = JobModel;
}
async add(data) {
const job = await this._JobModel.add(data);
return job;
}
async read(name) {
const job = await this._JobModel.findOne({name});
return job;
}
async update(id, data) {
await this._JobModel.edit(data, {id});
}
}
module.exports = JobsRepository;

View file

@ -0,0 +1,38 @@
{
"name": "@tryghost/job-manager",
"version": "0.9.0",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/job-manager",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@sinonjs/fake-timers": "9.1.2",
"c8": "7.12.0",
"date-fns": "2.29.1",
"delay": "5.0.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@breejs/later": "^4.0.2",
"@tryghost/logging": "^2.0.0",
"bree": "^6.2.0",
"cron-validate": "^1.4.3",
"fastq": "^1.11.0",
"p-wait-for": "^3.2.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View file

@ -0,0 +1,35 @@
/* eslint-disable no-console */
const pWaitFor = require('p-wait-for');
const path = require('path');
const setTimeoutPromise = require('util').promisify(setTimeout);
const JobManager = require('../../lib/job-manager');
const jobManager = new JobManager({
info: console.log,
warn: console.log,
error: console.log
});
process.on('SIGINT', () => {
shutdown('SIGINT');
});
async function shutdown(signal) {
console.log(`shutting down via: ${signal}`);
await jobManager.shutdown();
}
(async () => {
jobManager.addJob({
at: 'every 10 seconds',
job: path.resolve(__dirname, '../jobs/graceful.js')
});
await setTimeoutPromise(100); // allow job to get scheduled
await pWaitFor(() => (Object.keys(jobManager.bree.workers).length === 0) && (Object.keys(jobManager.bree.intervals).length === 0));
process.exit(0);
})();

View file

@ -0,0 +1,29 @@
const path = require('path');
const pWaitFor = require('p-wait-for');
const addSeconds = require('date-fns/addSeconds');
const JobManager = require('../../lib/job-manager');
const jobManager = new JobManager(console);
const isJobQueueEmpty = (bree) => {
return (Object.keys(bree.workers).length === 0)
&& (Object.keys(bree.intervals).length === 0)
&& (Object.keys(bree.timeouts).length === 0);
};
(async () => {
const dateInTenSeconds = addSeconds(new Date(), 10);
jobManager.addJob({
at: dateInTenSeconds,
job: path.resolve(__dirname, '../jobs/timed-job.js'),
data: {
ms: 2000
},
name: 'one-off-scheduled-job'
});
await pWaitFor(() => (isJobQueueEmpty(jobManager.bree)));
process.exit(0);
})();

View file

@ -0,0 +1,19 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const isCronExpression = require('../lib/is-cron-expression');
describe('Is cron expression', function () {
it('valid cron expressions', function () {
should(isCronExpression('* * * * * *')).be.true();
should(isCronExpression('1 * * * * *')).be.true();
should(isCronExpression('0 0 13-23 * * *'), 'Range should be 0-23').be.true();
});
it('invalid cron expressions', function () {
should(isCronExpression('0 123 * * * *')).not.be.true();
should(isCronExpression('a * * * *')).not.be.true();
should(isCronExpression('* 13-24 * * *'), 'Invalid range should be 0-23').not.be.true();
});
});

View file

@ -0,0 +1,429 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const assert = require('assert');
const path = require('path');
const sinon = require('sinon');
const delay = require('delay');
const FakeTimers = require('@sinonjs/fake-timers');
const logging = require('@tryghost/logging');
const JobManager = require('../index');
const sandbox = sinon.createSandbox();
describe('Job Manager', function () {
beforeEach(function () {
sandbox.stub(logging, 'info');
sandbox.stub(logging, 'warn');
sandbox.stub(logging, 'error');
});
afterEach(function () {
sandbox.restore();
});
it('public interface', function () {
const jobManager = new JobManager({});
should.exist(jobManager.addJob);
});
describe('Add a job', function () {
describe('Inline jobs', function () {
it('adds a job to a queue', async function () {
const spy = sinon.spy();
const jobManager = new JobManager({});
jobManager.addJob({
job: spy,
data: 'test data',
offloaded: false
});
should(jobManager.queue.idle()).be.false();
// give time to execute the job
await delay(1);
should(jobManager.queue.idle()).be.true();
should(spy.called).be.true();
should(spy.args[0][0]).equal('test data');
});
it('handles failed job gracefully', async function () {
const spy = sinon.stub().throws();
const jobManager = new JobManager({});
jobManager.addJob({
job: spy,
data: 'test data',
offloaded: false
});
should(jobManager.queue.idle()).be.false();
// give time to execute the job
await delay(1);
should(jobManager.queue.idle()).be.true();
should(spy.called).be.true();
should(spy.args[0][0]).equal('test data');
should(logging.error.called).be.true();
});
});
describe('Offloaded jobs', function () {
it('fails to schedule for invalid scheduling expression', function () {
const jobManager = new JobManager({});
try {
jobManager.addJob({
at: 'invalid expression',
name: 'jobName'
});
} catch (err) {
err.message.should.equal('Invalid schedule format');
}
});
it('fails to schedule for no job name', function () {
const jobManager = new JobManager({});
try {
jobManager.addJob({
at: 'invalid expression',
job: () => {}
});
} catch (err) {
err.message.should.equal('Name parameter should be present if job is a function');
}
});
it('schedules a job using date format', async function () {
const jobManager = new JobManager({});
const timeInTenSeconds = new Date(Date.now() + 10);
const jobPath = path.resolve(__dirname, './jobs/simple.js');
const clock = FakeTimers.install({now: Date.now()});
jobManager.addJob({
at: timeInTenSeconds,
job: jobPath,
name: 'job-in-ten'
});
should(jobManager.bree.timeouts['job-in-ten']).type('object');
should(jobManager.bree.workers['job-in-ten']).type('undefined');
// allow to run the job and start the worker
await clock.nextAsync();
should(jobManager.bree.workers['job-in-ten']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-in-ten'].on('error', reject);
jobManager.bree.workers['job-in-ten'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
// allow job to finish execution and exit
clock.next();
await promise;
should(jobManager.bree.workers['job-in-ten']).type('undefined');
clock.uninstall();
});
it('schedules a job to run immediately', async function () {
const jobManager = new JobManager({});
const clock = FakeTimers.install({now: Date.now()});
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
should(jobManager.bree.timeouts['job-now']).type('object');
// allow scheduler to pick up the job
clock.tick(1);
should(jobManager.bree.workers['job-now']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-now'].on('error', reject);
jobManager.bree.workers['job-now'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
await promise;
should(jobManager.bree.workers['job-now']).type('undefined');
clock.uninstall();
});
it('fails to schedule a job with the same name to run immediately one after another', async function () {
const jobManager = new JobManager({});
const clock = FakeTimers.install({now: Date.now()});
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
should(jobManager.bree.timeouts['job-now']).type('object');
// allow scheduler to pick up the job
clock.tick(1);
should(jobManager.bree.workers['job-now']).type('object');
const promise = new Promise((resolve, reject) => {
jobManager.bree.workers['job-now'].on('error', reject);
jobManager.bree.workers['job-now'].on('exit', (code) => {
should(code).equal(0);
resolve();
});
});
await promise;
should(jobManager.bree.workers['job-now']).type('undefined');
(() => {
jobManager.addJob({
job: jobPath,
name: 'job-now'
});
}).should.throw('Job #1 has a duplicate job name of job-now');
clock.uninstall();
});
it('uses custom error handler when job fails', async function (){
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler});
jobManager.addJob({
job,
name: 'will-fail'
});
// give time to execute the job
// has to be this long because in Node v10 the communication is
// done through processes, which takes longer comparing to worker_threads
// can be reduced to 100 when Node v10 support is dropped
await delay(600);
should(spyHandler.called).be.true();
should(spyHandler.args[0][0].message).equal('job error');
should(spyHandler.args[0][1].name).equal('will-fail');
});
it('uses worker message handler when job sends a message', async function (){
const workerMessageHandlerSpy = sinon.spy();
const jobManager = new JobManager({workerMessageHandler: workerMessageHandlerSpy});
jobManager.addJob({
job: path.resolve(__dirname, './jobs/message.js'),
name: 'will-send-msg'
});
jobManager.bree.run('will-send-msg');
jobManager.bree.workers['will-send-msg'].postMessage('hello from Ghost!');
// Give time for worker (worker thread) <-> parent process (job manager) communication
await delay(1000);
should(workerMessageHandlerSpy.called).be.true();
should(workerMessageHandlerSpy.args[0][0].name).equal('will-send-msg');
should(workerMessageHandlerSpy.args[0][0].message).equal('Worker received: hello from Ghost!');
});
});
});
describe('Add one off job', function () {
it('adds job to the queue when it is a unique one', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves(undefined),
add: sinon.stub().resolves()
};
const jobManager = new JobManager({JobModel});
await jobManager.addOneOffJob({
job: spy,
name: 'unique name',
data: 'test data'
});
assert.equal(JobModel.add.called, true);
});
it('does not add a job to the queue when it already exists', async function () {
const spy = sinon.spy();
const JobModel = {
findOne: sinon.stub().resolves({name: 'I am the only one'}),
add: sinon.stub().throws('should not be called')
};
const jobManager = new JobManager({JobModel});
try {
await jobManager.addOneOffJob({
job: spy,
name: 'I am the only one',
data: 'test data'
});
throw new Error('should not reach this point');
} catch (error) {
assert.equal(error.message, 'A "I am the only one" one off job has already been executed.');
}
});
it('sets a finished state on a job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves({id: 'unique', name: 'successful-oneoff'}),
add: sinon.stub().resolves({name: 'successful-oneoff'}),
edit: sinon.stub().resolves({name: 'successful-oneoff'})
};
const jobManager = new JobManager({JobModel});
jobManager.addOneOffJob({
job: path.resolve(__dirname, './jobs/message.js'),
name: 'successful-oneoff'
});
// allow job to get picked up and executed
await delay(100);
jobManager.bree.workers['successful-oneoff'].postMessage('be done!');
// allow the message to be passed around
await delay(100);
// tracks the job start
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job finish
should(JobModel.edit.args[1][0].status).equal('finished');
should(JobModel.edit.args[1][0].finished_at).not.equal(undefined);
should(JobModel.edit.args[1][1].id).equal('unique');
});
it('sets a failed state on a job', async function () {
const JobModel = {
findOne: sinon.stub()
.onCall(0)
.resolves(null)
.resolves({id: 'unique', name: 'failed-oneoff'}),
add: sinon.stub().resolves({name: 'failed-oneoff'}),
edit: sinon.stub().resolves({name: 'failed-oneoff'})
};
let job = function namedJob() {
throw new Error('job error');
};
const spyHandler = sinon.spy();
const jobManager = new JobManager({errorHandler: spyHandler, JobModel});
jobManager.addOneOffJob({
job,
name: 'failed-oneoff'
});
// give time to execute the job
// has to be this long because in Node v10 the communication is
// done through processes, which takes longer comparing to worker_threads
// can be reduced to 100 when Node v10 support is dropped
await delay(100);
// still calls the original error handler
should(spyHandler.called).be.true();
should(spyHandler.args[0][0].message).equal('job error');
should(spyHandler.args[0][1].name).equal('failed-oneoff');
// tracks the job start
should(JobModel.edit.args[0][0].status).equal('started');
should(JobModel.edit.args[0][0].started_at).not.equal(undefined);
should(JobModel.edit.args[0][1].id).equal('unique');
// tracks the job failure
should(JobModel.edit.args[1][0].status).equal('failed');
should(JobModel.edit.args[1][1].id).equal('unique');
});
});
describe('Remove a job', function () {
it('removes a scheduled job from the queue', async function () {
const jobManager = new JobManager({});
const timeInTenSeconds = new Date(Date.now() + 10);
const jobPath = path.resolve(__dirname, './jobs/simple.js');
jobManager.addJob({
at: timeInTenSeconds,
job: jobPath,
name: 'job-in-ten'
});
jobManager.bree.config.jobs[0].name.should.equal('job-in-ten');
await jobManager.removeJob('job-in-ten');
should(jobManager.bree.config.jobs[0]).be.undefined;
});
});
describe('Shutdown', function () {
it('gracefully shuts down an inline jobs', async function () {
const jobManager = new JobManager({});
jobManager.addJob({
job: require('./jobs/timed-job'),
data: 200,
offloaded: false
});
should(jobManager.queue.idle()).be.false();
await jobManager.shutdown();
should(jobManager.queue.idle()).be.true();
});
it('gracefully shuts down an interval job', async function () {
const jobManager = new JobManager({});
jobManager.addJob({
at: 'every 5 seconds',
job: path.resolve(__dirname, './jobs/graceful.js')
});
await delay(1); // let the job execution kick in
should(Object.keys(jobManager.bree.workers).length).equal(0);
should(Object.keys(jobManager.bree.timeouts).length).equal(0);
should(Object.keys(jobManager.bree.intervals).length).equal(1);
await jobManager.shutdown();
should(Object.keys(jobManager.bree.intervals).length).equal(0);
});
});
});

View file

@ -0,0 +1,49 @@
/* eslint-disable no-console */
const setTimeoutPromise = require('util').promisify(setTimeout);
const {isMainThread, parentPort} = require('bthreads');
let shutdown = false;
if (!isMainThread) {
parentPort.on('message', (message) => {
console.log(`parent message received: ${message}`);
// 'cancel' event is triggered when job has to to terminated before it finishes execution
// usually it would come in when SIGINT signal is sent to a parent process
if (message === 'cancel') {
shutdown = true;
}
});
}
(async () => {
console.log('started graceful job');
for (;;) {
await setTimeoutPromise(1000);
console.log('worked for 1000 ms');
if (shutdown) {
console.log('exiting gracefully');
await setTimeoutPromise(100); // async cleanup imitation
if (parentPort) {
// `done' is a preferred method of shutting down the worker
// it signals job manager about finished job and the thread
// is later terminated through `terminate()` method allowing
// for unfinished pipes to flush (e.g. loggers)
//
// 'cancelled' is an allternative method to signal job was terminated
// because of parent initiated reason (e.g.: parent process interuption)
// differs from 'done' by producing different
// logging - shows the job was cancelled instead of completing
parentPort.postMessage('done');
// parentPort.postMessage('cancelled');
} else {
process.exit(0);
}
}
}
})();

View file

@ -0,0 +1,20 @@
const {parentPort} = require('bthreads');
setInterval(() => { }, 10);
if (parentPort) {
parentPort.on('message', (message) => {
if (message === 'error') {
throw new Error('oops');
}
if (message === 'cancel') {
parentPort.postMessage('cancelled');
return;
}
// post the message back
parentPort.postMessage(`Worker received: ${message}`);
parentPort.postMessage('done');
});
}

View file

@ -0,0 +1 @@
setInterval(() => process.exit(0), 10);

View file

@ -0,0 +1,22 @@
const {isMainThread, parentPort, workerData} = require('bthreads');
const util = require('util');
const setTimeoutPromise = util.promisify(setTimeout);
const passTime = async (ms) => {
if (Number.isInteger(ms)) {
await setTimeoutPromise(ms);
} else {
await setTimeoutPromise(ms.ms);
}
};
if (isMainThread) {
module.exports = passTime;
} else {
(async () => {
await passTime(workerData.ms);
parentPort.postMessage('done');
// alternative way to signal "finished" work (not recommended)
// process.exit();
})();
}

View file

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View file

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View file

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

21
ghost/minifier/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
ghost/minifier/README.md Normal file
View file

@ -0,0 +1,56 @@
# Minifier
## Install
`npm install @tryghost/minifier --save`
or
`yarn add @tryghost/minifier`
## Usage
```
const Minifier = require('@tryghost/minifier');
const minifier = new Minifier({
src: 'my/src/path',
dest: 'my/dest/path'
});
minifier.minify({
'some.css': '*.css',
'then.js': '!(other).js'
});
```
- Minfier constructor requires a src and a dest
- minify() function takes an object with destination file as the key and source glob as the value
- globs can be anything tiny-glob supports
- destination files must end with .css or .js
- src files will be minified according to their destination file extension
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

1
ghost/minifier/index.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('./lib/minifier');

View file

@ -0,0 +1,145 @@
const errors = require('@tryghost/errors');
const debug = require('@tryghost/debug')('minifier');
const tpl = require('@tryghost/tpl');
const csso = require('csso');
const terser = require('terser');
const glob = require('tiny-glob');
const path = require('path');
const fs = require('fs').promises;
const messages = {
badDestination: {
message: 'Unexpected destination {dest}',
context: 'Minifier expected a destination that ended in .css or .js'
},
badSource: {
message: 'Unable to read source files {src}',
context: 'Minifier was unable to locate or read the source files'
},
missingConstructorOption: {
message: 'Minifier missing {opt} option',
context: 'new Minifier({}) requires a {opt} option'
},
globalHelp: 'Refer to the readme for @tryghost/minifier for how to use this module'
};
// public API for minify hooks
class Minifier {
constructor({src, dest}) {
if (!src) {
throw new errors.IncorrectUsageError({
message: tpl(messages.missingConstructorOption.message, {opt: 'src'}),
context: tpl(messages.missingConstructorOption.context, {opt: 'src'}),
help: tpl(messages.globalHelp)
});
}
if (!dest) {
throw new errors.IncorrectUsageError({
message: tpl(messages.missingConstructorOption.message, {opt: 'dest'}),
context: tpl(messages.missingConstructorOption.context, {opt: 'dest'}),
help: tpl(messages.globalHelp)
});
}
this.srcPath = src;
this.destPath = dest;
}
getFullSrc(src) {
return path.join(this.srcPath, src);
}
getFullDest(dest) {
return path.join(this.destPath, dest);
}
async minifyCSS(contents) {
const result = await csso.minify(contents);
if (result && result.css) {
return result.css;
}
return null;
}
async minifyJS(contents) {
const result = await terser.minify(contents);
if (result && result.code) {
return result.code;
}
return null;
}
async getMatchingFiles(src) {
return await glob(this.getFullSrc(src));
}
async readFiles(files) {
let mergedFiles = '';
for (const file of files) {
const contents = await fs.readFile(file, 'utf8');
mergedFiles += contents;
}
return mergedFiles;
}
async getSrcFileContents(src) {
try {
const files = await this.getMatchingFiles(src);
if (files) {
return await this.readFiles(files);
}
} catch (error) {
throw new errors.IncorrectUsageError({
message: tpl(messages.badSource.message, {src}),
context: tpl(messages.badSource.context),
help: tpl(messages.globalHelp)
});
}
}
async writeFile(contents, dest) {
if (contents) {
let writePath = this.getFullDest(dest);
// Ensure the output folder exists
await fs.mkdir(this.destPath, {recursive: true});
// Create the file
await fs.writeFile(writePath, contents);
return writePath;
}
}
async minify(options) {
debug('Begin', options);
const destinations = Object.keys(options);
const minifiedFiles = [];
for (const dest of destinations) {
const src = options[dest];
const contents = await this.getSrcFileContents(src);
let minifiedContents;
if (dest.endsWith('.css')) {
minifiedContents = await this.minifyCSS(contents);
} else if (dest.endsWith('.js')) {
minifiedContents = await this.minifyJS(contents);
} else {
throw new errors.IncorrectUsageError({
message: tpl(messages.badDestination.message, {dest}),
context: tpl(messages.badDestination.context),
help: tpl(messages.globalHelp)
});
}
const result = await this.writeFile(minifiedContents, dest);
if (result) {
minifiedFiles.push(dest);
}
}
debug('End');
return minifiedFiles;
}
}
module.exports = Minifier;

View file

@ -0,0 +1,35 @@
{
"name": "@tryghost/minifier",
"version": "0.1.17",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/minifier",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/debug": "^0.1.8",
"@tryghost/errors": "^1.2.1",
"@tryghost/tpl": "^0.1.7",
"csso": "^5.0.0",
"terser": "^5.9.0",
"tiny-glob": "^0.2.9"
}
}

View file

@ -0,0 +1,7 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
],
ignorePatterns: ['fixtures']
};

View file

@ -0,0 +1,83 @@
/* style.css */
.kg-bookmark-card {
width: 100%;
position: relative;
}
.kg-bookmark-container {
display: flex;
flex-wrap: wrap;
flex-direction: row-reverse;
color: currentColor;
font-family: inherit;
text-decoration: none;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.kg-bookmark-container:hover {
text-decoration: none;
}
.kg-bookmark-content {
flex-basis: 0;
flex-grow: 999;
padding: 20px;
order: 1;
}
.kg-bookmark-title {
font-weight: 600;
}
.kg-bookmark-metadata,
.kg-bookmark-description {
margin-top: .5em;
}
.kg-bookmark-metadata {
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kg-bookmark-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.kg-bookmark-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: text-bottom;
margin-right: .5em;
margin-bottom: .05em;
}
.kg-bookmark-thumbnail {
display: flex;
flex-basis: 24rem;
flex-grow: 1;
}
.kg-bookmark-thumbnail img {
max-width: 100%;
height: auto;
vertical-align: bottom;
object-fit: cover;
}
.kg-bookmark-author {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.kg-bookmark-publisher::before {
content: "•";
margin: 0 .5em;
}

View file

View file

@ -0,0 +1,36 @@
.kg-gallery-card {
margin: 0 0 1.5em;
}
.kg-gallery-card figcaption {
margin: -1.0em 0 1.5em;
}
.kg-gallery-container {
display: flex;
flex-direction: column;
margin: 1.5em auto;
max-width: 1040px;
width: 100vw;
}
.kg-gallery-row {
display: flex;
flex-direction: row;
justify-content: center;
}
.kg-gallery-image img {
display: block;
margin: 0;
width: 100%;
height: 100%;
}
.kg-gallery-row:not(:first-of-type) {
margin: 0.75em 0 0 0;
}
.kg-gallery-image:not(:first-of-type) {
margin: 0 0 0 0.75em;
}

View file

View file

@ -0,0 +1,8 @@
var images = document.querySelectorAll('.kg-gallery-image img');
images.forEach(function (image) {
var container = image.closest('.kg-gallery-image');
var width = image.attributes.width.value;
var height = image.attributes.height.value;
var ratio = width / height;
container.style.flex = ratio + ' 1 0%';
})

View file

@ -0,0 +1,138 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const Minifier = require('../lib/minifier');
describe('Minifier', function () {
let minifier;
let testDir;
before(async function () {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'minifier-tests-'));
minifier = new Minifier({
src: path.join(__dirname, 'fixtures', 'basic-cards'),
dest: testDir
});
});
after(async function () {
await fs.rmdir(testDir, {recursive: true});
});
describe('getMatchingFiles expands globs correctly', function () {
it('star glob e.g. css/*.css', async function () {
let result = await minifier.getMatchingFiles('css/*.css');
result.should.be.an.Array().with.lengthOf(3);
result[0].should.eql('test/fixtures/basic-cards/css/bookmark.css');
result[1].should.eql('test/fixtures/basic-cards/css/empty.css');
result[2].should.eql('test/fixtures/basic-cards/css/gallery.css');
});
it('reverse match glob e.g. css/!(bookmark).css', async function () {
let result = await minifier.getMatchingFiles('css/!(bookmark).css');
result.should.be.an.Array().with.lengthOf(2);
result[0].should.eql('test/fixtures/basic-cards/css/empty.css');
result[1].should.eql('test/fixtures/basic-cards/css/gallery.css');
});
it('reverse match glob e.g. css/!(bookmark|gallery).css', async function () {
let result = await minifier.getMatchingFiles('css/!(bookmark|gallery).css');
result.should.be.an.Array().with.lengthOf(1);
result[0].should.eql('test/fixtures/basic-cards/css/empty.css');
});
});
describe('Minify', function () {
it('single type, single file', async function () {
let result = await minifier.minify({
'card.min.js': 'js/*.js'
});
result.should.be.an.Array().with.lengthOf(1);
});
it('single type, multi file', async function () {
let result = await minifier.minify({
'card.min.css': 'css/*.css'
});
result.should.be.an.Array().with.lengthOf(1);
});
it('both css and js types + multiple files', async function () {
let result = await minifier.minify({
'card.min.js': 'js/*.js',
'card.min.css': 'css/*.css'
});
result.should.be.an.Array().with.lengthOf(2);
});
});
describe('Bad inputs', function () {
it('cannot create a minifier without src and dest', function () {
(function noObject(){
new Minifier();
}).should.throw();
(function emptyObject() {
new Minifier({});
}).should.throw();
(function missingSrc() {
new Minifier({dest: 'a'});
}).should.throw();
(function missingDest() {
new Minifier({src: 'a'});
}).should.throw();
});
it('can only handle css and js files', async function () {
try {
await minifier.minify({
'card.min.ts': 'js/*.ts'
});
should.fail(minifier, 'Should have errored');
} catch (err) {
should.exist(err);
err.errorType.should.eql('IncorrectUsageError');
err.message.should.match(/Unexpected destination/);
}
});
it('can handle missing files and folders gracefully', async function () {
try {
await minifier.minify({
'card.min.ts': 'ts/*.ts',
'card.min.js': 'js/fake.js'
});
should.fail(minifier, 'Should have errored');
} catch (err) {
should.exist(err);
err.errorType.should.eql('IncorrectUsageError');
err.message.should.match(/Unable to read/);
}
});
it('can minify empty js correctly to no result', async function () {
let result = await minifier.minify({
'card.min.js': 'js/empty.js'
});
result.should.be.an.Array().with.lengthOf(0);
});
it('can minify empty css correctly to no result', async function () {
let result = await minifier.minify({
'card.min.css': 'css/empty.css'
});
result.should.be.an.Array().with.lengthOf(0);
});
});
});

View file

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View file

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View file

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

Some files were not shown because too many files have changed in this diff Show more