ghost 4.21.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Gruntfile.js +1 -0
- package/content/themes/casper/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +263 -50
- package/content/themes/casper/default.hbs +12 -3
- package/content/themes/casper/index.hbs +25 -23
- package/content/themes/casper/package.json +91 -2
- package/content/themes/casper/partials/post-card.hbs +1 -1
- package/content/themes/casper/post.hbs +18 -14
- package/content/themes/casper/yarn.lock +245 -192
- package/core/boot.js +5 -0
- package/core/bridge.js +14 -0
- package/core/built/assets/{chunk.3.065ee3c3bdf674bd81a4.js → chunk.3.1148677ff3b78e5aeaee.js} +60 -60
- package/core/built/assets/{ghost-dark-1328db4a7dd128305646305a8731bcfe.css → ghost-dark-684ad238e1a858c7cb5be6988de7c6f5.css} +1 -1
- package/core/built/assets/{ghost.min-5abc69c04ad1d5301a857e01009b9c05.css → ghost.min-66e08535f8bb797a8c40e0a2b31f1e9e.css} +1 -1
- package/core/built/assets/{ghost.min-6c546c322127ae6d1d1b0ddbf34be75b.js → ghost.min-efbfb823467b66f4acc66537d033aa55.js} +1742 -1891
- package/core/built/assets/{vendor.min-c6ef90bfd7eff256e10b85583bfe9a74.js → vendor.min-7c8fdd90f7ecd2e94328a07ea3b64608.js} +601 -571
- package/core/frontend/helpers/asset.js +9 -1
- package/core/frontend/helpers/ghost_head.js +13 -1
- package/core/frontend/services/card-assets/index.js +16 -0
- package/core/frontend/services/card-assets/service.js +101 -0
- package/core/frontend/services/theme-engine/config/defaults.json +4 -1
- package/core/frontend/services/theme-engine/config/index.js +1 -1
- package/core/frontend/src/cards/css/bookmark.css +83 -0
- package/core/frontend/src/cards/css/gallery.css +36 -0
- package/core/frontend/src/cards/js/gallery.js +8 -0
- package/core/frontend/web/middleware/serve-public-file.js +10 -1
- package/core/frontend/web/site.js +10 -9
- package/core/server/adapters/storage/LocalImagesStorage.js +50 -0
- package/core/server/adapters/storage/LocalMediaStorage.js +23 -0
- package/core/server/adapters/storage/{LocalFileStorage.js → LocalStorageBase.js} +36 -48
- package/core/server/adapters/storage/index.js +1 -1
- package/core/server/adapters/storage/utils.js +2 -2
- package/core/server/api/canary/index.js +4 -0
- package/core/server/api/canary/media.js +22 -0
- package/core/server/api/canary/redirects.js +1 -6
- package/core/server/api/canary/utils/serializers/input/pages.js +8 -0
- package/core/server/api/canary/utils/serializers/output/index.js +4 -0
- package/core/server/api/canary/utils/serializers/output/media.js +28 -0
- package/core/server/api/canary/utils/validators/input/index.js +4 -0
- package/core/server/api/canary/utils/validators/input/media.js +7 -0
- package/core/server/api/v2/redirects.js +1 -6
- package/core/server/api/v3/members.js +5 -1
- package/core/server/api/v3/redirects.js +1 -6
- package/core/server/data/migrations/utils.js +55 -16
- package/core/server/data/migrations/versions/4.22/01-add-is-launch-complete-setting.js +8 -0
- package/core/server/data/migrations/versions/4.22/02-update-launch-complete-setting-from-user-data.js +39 -0
- package/core/server/data/schema/default-settings.json +8 -0
- package/core/server/frontend/ghost.min.css +1 -1
- package/core/server/lib/image/blog-icon.js +2 -4
- package/core/server/lib/image/image-size.js +1 -1
- package/core/server/services/limits.js +3 -6
- package/core/server/services/mega/template.js +4 -0
- package/core/server/services/offers/service.js +1 -31
- package/core/server/services/redirects/api.js +270 -0
- package/core/server/services/redirects/index.js +27 -12
- package/core/server/services/themes/ThemeStorage.js +5 -5
- package/core/server/web/admin/views/default-prod.html +4 -4
- package/core/server/web/admin/views/default.html +4 -4
- package/core/server/web/api/canary/admin/routes.js +13 -4
- package/core/server/web/api/middleware/upload.js +117 -10
- package/core/server/web/members/app.js +1 -1
- package/core/server/web/shared/middlewares/index.js +0 -4
- package/core/shared/config/defaults.json +3 -1
- package/core/shared/config/helpers.js +2 -0
- package/core/shared/config/overrides.json +8 -0
- package/core/shared/labs.js +5 -3
- package/package.json +14 -13
- package/yarn.lock +875 -851
- package/core/built/assets/img/themes/Editorial-a25a4a34c04dedd858bd5e05ef388b1c.jpg +0 -0
- package/core/built/assets/img/themes/Massively-06edf00108429f7fb8e65f190fba34fe.jpg +0 -0
- package/core/server/services/redirects/settings.js +0 -234
- package/core/server/web/shared/middlewares/custom-redirects.js +0 -128
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const moment = require('moment-timezone');
|
|
4
|
+
const yaml = require('js-yaml');
|
|
5
|
+
|
|
6
|
+
const logging = require('@tryghost/logging');
|
|
7
|
+
const tpl = require('@tryghost/tpl');
|
|
8
|
+
const errors = require('@tryghost/errors');
|
|
9
|
+
|
|
10
|
+
const validation = require('./validation');
|
|
11
|
+
|
|
12
|
+
const messages = {
|
|
13
|
+
jsonParse: 'Could not parse JSON: {context}.',
|
|
14
|
+
yamlParse: 'Could not parse YAML: {context}.',
|
|
15
|
+
yamlPlainString: 'YAML input cannot be a plain string. Check the format of your YAML file.',
|
|
16
|
+
redirectsHelp: 'https://ghost.org/docs/themes/routing/#redirects',
|
|
17
|
+
redirectsRegister: 'Could not register custom redirects.'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Redirect configuration object
|
|
22
|
+
* @typedef {Object} RedirectConfig
|
|
23
|
+
* @property {String} from - Defines the relative incoming URL or pattern (regex)
|
|
24
|
+
* @property {String} to - Defines where the incoming traffic should be redirected to, which can be a static URL, or a dynamic value using regex (example: "to": "/$1/")
|
|
25
|
+
* @property {boolean} permanent - Can be defined with true for a permanent HTTP 301 redirect, or false for a temporary HTTP 302 redirect
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} redirectsPath
|
|
30
|
+
* @returns {Promise<string>}
|
|
31
|
+
*/
|
|
32
|
+
const readRedirectsFile = async (redirectsPath) => {
|
|
33
|
+
try {
|
|
34
|
+
return await fs.readFile(redirectsPath, 'utf-8');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT') {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (errors.utils.isIgnitionError(err)) {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new errors.NotFoundError({
|
|
45
|
+
err: err
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
*
|
|
52
|
+
* @param {String} content serialized JSON or YAML configuration
|
|
53
|
+
* @param {String} ext one of `.json` or `.yaml` extensions
|
|
54
|
+
*
|
|
55
|
+
* @returns {RedirectConfig[]} of parsed redirect config objects
|
|
56
|
+
*/
|
|
57
|
+
const parseRedirectsFile = (content, ext) => {
|
|
58
|
+
if (ext === '.json') {
|
|
59
|
+
let redirects;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
redirects = JSON.parse(content);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new errors.BadRequestError({
|
|
65
|
+
message: tpl(messages.jsonParse, {context: err.message})
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return redirects;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ext === '.yaml') {
|
|
73
|
+
let redirects = [];
|
|
74
|
+
let configYaml;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
configYaml = yaml.load(content);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new errors.BadRequestError({
|
|
80
|
+
message: tpl(messages.yamlParse, {context: err.message})
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// yaml.load passes almost every yaml code.
|
|
85
|
+
// Because of that, it's hard to detect if there's an error in the file.
|
|
86
|
+
// But one of the obvious errors is the plain string output.
|
|
87
|
+
// Here we check if the user made this mistake.
|
|
88
|
+
if (typeof configYaml === 'string') {
|
|
89
|
+
throw new errors.BadRequestError({
|
|
90
|
+
message: tpl(messages.yamlPlainString),
|
|
91
|
+
help: tpl(messages.redirectsHelp)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 302: Temporary redirects
|
|
97
|
+
*/
|
|
98
|
+
for (const redirect in configYaml['302']) {
|
|
99
|
+
redirects.push({
|
|
100
|
+
from: redirect,
|
|
101
|
+
to: configYaml['302'][redirect],
|
|
102
|
+
permanent: false
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 301: Permanent redirects
|
|
108
|
+
*/
|
|
109
|
+
for (const redirect in configYaml['301']) {
|
|
110
|
+
redirects.push({
|
|
111
|
+
from: redirect,
|
|
112
|
+
to: configYaml['301'][redirect],
|
|
113
|
+
permanent: true
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return redirects;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new errors.IncorrectUsageError();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} filePath
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
const getBackupRedirectsFilePath = (filePath) => {
|
|
128
|
+
const {dir, name, ext} = path.parse(filePath);
|
|
129
|
+
|
|
130
|
+
return path.join(dir, `${name}-${moment().format('YYYY-MM-DD-HH-mm-ss')}${ext}`);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {object} IRedirectManager
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
class CustomRedirectsAPI {
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} config
|
|
140
|
+
* @param {string} config.basePath
|
|
141
|
+
*
|
|
142
|
+
* @param {IRedirectManager} redirectManager
|
|
143
|
+
*/
|
|
144
|
+
constructor(config, redirectManager) {
|
|
145
|
+
/** @private */
|
|
146
|
+
this.config = config;
|
|
147
|
+
/** @private */
|
|
148
|
+
this.redirectManager = redirectManager;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async init() {
|
|
152
|
+
// NOTE: the try/catch block here is due to possible breaking change for existing misconfigured
|
|
153
|
+
// instances in the wild. Would be a good idea to remove it during v5 migration to enforce
|
|
154
|
+
// fail-fast initialization.
|
|
155
|
+
try {
|
|
156
|
+
const filePath = await this.getRedirectsFilePath();
|
|
157
|
+
|
|
158
|
+
if (filePath !== null) {
|
|
159
|
+
const content = await readRedirectsFile(filePath);
|
|
160
|
+
const ext = path.extname(filePath);
|
|
161
|
+
const redirects = parseRedirectsFile(content, ext);
|
|
162
|
+
validation.validate(redirects);
|
|
163
|
+
|
|
164
|
+
this.redirectManager.removeAllRedirects();
|
|
165
|
+
for (const redirect of redirects) {
|
|
166
|
+
this.redirectManager.addRedirect(redirect.from, redirect.to, {permanent: redirect.permanent});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (errors.utils.isIgnitionError(err)) {
|
|
171
|
+
logging.error(err);
|
|
172
|
+
} else {
|
|
173
|
+
logging.error(new errors.IncorrectUsageError({
|
|
174
|
+
message: tpl(messages.redirectsRegister),
|
|
175
|
+
context: err.message,
|
|
176
|
+
help: tpl(messages.redirectsHelp),
|
|
177
|
+
err
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @private
|
|
185
|
+
* @param {'.yaml'|'.json'} ext
|
|
186
|
+
*
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
createRedirectsFilePath(ext) {
|
|
190
|
+
return path.join(this.config.basePath, `redirects${ext}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @returns {Promise<string>}
|
|
195
|
+
*/
|
|
196
|
+
async getRedirectsFilePath() {
|
|
197
|
+
const yamlPath = this.createRedirectsFilePath('.yaml');
|
|
198
|
+
const jsonPath = this.createRedirectsFilePath('.json');
|
|
199
|
+
|
|
200
|
+
const yamlExists = await fs.pathExists(yamlPath);
|
|
201
|
+
|
|
202
|
+
if (yamlExists) {
|
|
203
|
+
return yamlPath;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const jsonExist = await fs.pathExists(jsonPath);
|
|
207
|
+
|
|
208
|
+
if (jsonExist) {
|
|
209
|
+
return jsonPath;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {string} filePath
|
|
217
|
+
* @param {'.yaml'|'.json'} [ext]
|
|
218
|
+
*
|
|
219
|
+
* @returns {Promise<>}
|
|
220
|
+
*/
|
|
221
|
+
async setFromFilePath(filePath, ext = '.json') {
|
|
222
|
+
const redirectsFilePath = await this.getRedirectsFilePath();
|
|
223
|
+
|
|
224
|
+
if (redirectsFilePath) {
|
|
225
|
+
const backupRedirectsPath = getBackupRedirectsFilePath(redirectsFilePath);
|
|
226
|
+
|
|
227
|
+
const backupExists = await fs.pathExists(backupRedirectsPath);
|
|
228
|
+
if (backupExists) {
|
|
229
|
+
await fs.unlink(backupRedirectsPath);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await fs.move(redirectsFilePath, backupRedirectsPath);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const content = await readRedirectsFile(filePath);
|
|
236
|
+
const parsed = parseRedirectsFile(content, ext);
|
|
237
|
+
validation.validate(parsed);
|
|
238
|
+
|
|
239
|
+
if (ext === '.json') {
|
|
240
|
+
await fs.writeFile(this.createRedirectsFilePath('.json'), JSON.stringify(content), 'utf-8');
|
|
241
|
+
} else if (ext === '.yaml') {
|
|
242
|
+
await fs.copy(filePath, this.createRedirectsFilePath('.yaml'));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.redirectManager.removeAllRedirects();
|
|
246
|
+
for (const redirect of parsed) {
|
|
247
|
+
this.redirectManager.addRedirect(redirect.from, redirect.to, {permanent: redirect.permanent});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @returns {Promise<RedirectConfig[]>}
|
|
253
|
+
*/
|
|
254
|
+
async get() {
|
|
255
|
+
const filePath = await this.getRedirectsFilePath();
|
|
256
|
+
if (filePath === null) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const content = await readRedirectsFile(filePath);
|
|
261
|
+
|
|
262
|
+
if (path.extname(filePath) === '.json') {
|
|
263
|
+
return parseRedirectsFile(content, '.json');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return content;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = CustomRedirectsAPI;
|
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
1
|
+
const config = require('../../../shared/config');
|
|
2
|
+
const urlUtils = require('../../../shared/url-utils');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
getRedirectsFilePath: settings.getRedirectsFilePath,
|
|
12
|
-
get: settings.get,
|
|
13
|
-
setFromFilePath: settings.setFromFilePath
|
|
4
|
+
const DynamicRedirectManager = require('@tryghost/express-dynamic-redirects');
|
|
5
|
+
const CustomRedirectsAPI = require('./api');
|
|
6
|
+
|
|
7
|
+
const redirectManager = new DynamicRedirectManager({
|
|
8
|
+
permanentMaxAge: config.get('caching:customRedirects:maxAge'),
|
|
9
|
+
getSubdirectoryURL: (pathname) => {
|
|
10
|
+
return urlUtils.urlJoin(urlUtils.getSubdir(), pathname);
|
|
14
11
|
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
let customRedirectsAPI;
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
init() {
|
|
18
|
+
customRedirectsAPI = new CustomRedirectsAPI({
|
|
19
|
+
basePath: config.getContentPath('data')
|
|
20
|
+
}, redirectManager);
|
|
21
|
+
|
|
22
|
+
return customRedirectsAPI.init();
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
get api() {
|
|
26
|
+
return customRedirectsAPI;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
middleware: redirectManager.handleRequest
|
|
15
30
|
};
|
|
@@ -4,16 +4,16 @@ const path = require('path');
|
|
|
4
4
|
const config = require('../../../shared/config');
|
|
5
5
|
const security = require('@tryghost/security');
|
|
6
6
|
const {compress} = require('@tryghost/zip');
|
|
7
|
-
const
|
|
7
|
+
const LocalStorageBase = require('../../adapters/storage/LocalStorageBase');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @TODO: combine with loader.js?
|
|
11
11
|
*/
|
|
12
|
-
class ThemeStorage extends
|
|
12
|
+
class ThemeStorage extends LocalStorageBase {
|
|
13
13
|
constructor() {
|
|
14
|
-
super(
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
super({
|
|
15
|
+
storagePath: config.getContentPath('themes')
|
|
16
|
+
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
getTargetDir() {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Ghost Admin</title>
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.
|
|
11
|
+
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.22%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
|
|
12
12
|
|
|
13
13
|
<meta name="HandheldFriendly" content="True" />
|
|
14
14
|
<meta name="MobileOptimized" content="320" />
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
<link rel="stylesheet" href="assets/vendor.min-987af30228885bce50f05c4723fe6f53.css">
|
|
44
|
-
<link rel="stylesheet" href="assets/ghost.min-
|
|
44
|
+
<link rel="stylesheet" href="assets/ghost.min-66e08535f8bb797a8c40e0a2b31f1e9e.css" title="light">
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
<div id="ember-basic-dropdown-wormhole"></div>
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
<script src="assets/vendor.min-
|
|
63
|
-
<script src="assets/ghost.min-
|
|
62
|
+
<script src="assets/vendor.min-7c8fdd90f7ecd2e94328a07ea3b64608.js"></script>
|
|
63
|
+
<script src="assets/ghost.min-efbfb823467b66f4acc66537d033aa55.js"></script>
|
|
64
64
|
|
|
65
65
|
</body>
|
|
66
66
|
</html>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Ghost Admin</title>
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.
|
|
11
|
+
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.22%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22emberKeyboard%22%3A%7B%22disableInputsInitializer%22%3Atrue%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
|
|
12
12
|
|
|
13
13
|
<meta name="HandheldFriendly" content="True" />
|
|
14
14
|
<meta name="MobileOptimized" content="320" />
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
<link rel="stylesheet" href="assets/vendor.min-987af30228885bce50f05c4723fe6f53.css">
|
|
44
|
-
<link rel="stylesheet" href="assets/ghost.min-
|
|
44
|
+
<link rel="stylesheet" href="assets/ghost.min-66e08535f8bb797a8c40e0a2b31f1e9e.css" title="light">
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
<div id="ember-basic-dropdown-wormhole"></div>
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
<script src="assets/vendor.min-
|
|
63
|
-
<script src="assets/ghost.min-
|
|
62
|
+
<script src="assets/vendor.min-7c8fdd90f7ecd2e94328a07ea3b64608.js"></script>
|
|
63
|
+
<script src="assets/ghost.min-efbfb823467b66f4acc66537d033aa55.js"></script>
|
|
64
64
|
|
|
65
65
|
</body>
|
|
66
66
|
</html>
|
|
@@ -100,10 +100,10 @@ module.exports = function apiRoutes() {
|
|
|
100
100
|
router.del('/members', mw.authAdminApi, http(api.members.bulkDestroy));
|
|
101
101
|
router.put('/members/bulk', mw.authAdminApi, http(api.members.bulkEdit));
|
|
102
102
|
|
|
103
|
-
router.get('/offers',
|
|
104
|
-
router.post('/offers',
|
|
105
|
-
router.get('/offers/:id',
|
|
106
|
-
router.put('/offers/:id',
|
|
103
|
+
router.get('/offers', mw.authAdminApi, http(api.offers.browse));
|
|
104
|
+
router.post('/offers', mw.authAdminApi, http(api.offers.add));
|
|
105
|
+
router.get('/offers/:id', mw.authAdminApi, http(api.offers.read));
|
|
106
|
+
router.put('/offers/:id', mw.authAdminApi, http(api.offers.edit));
|
|
107
107
|
|
|
108
108
|
router.get('/members/stats/count', mw.authAdminApi, http(api.members.memberStats));
|
|
109
109
|
router.get('/members/stats/mrr', mw.authAdminApi, http(api.members.mrrStats));
|
|
@@ -236,6 +236,15 @@ module.exports = function apiRoutes() {
|
|
|
236
236
|
http(api.images.upload)
|
|
237
237
|
);
|
|
238
238
|
|
|
239
|
+
// ## media
|
|
240
|
+
router.post('/media/upload',
|
|
241
|
+
labs.enabledMiddleware('mediaAPI'),
|
|
242
|
+
mw.authAdminApi,
|
|
243
|
+
apiMw.upload.media('file', 'thumbnail'),
|
|
244
|
+
apiMw.upload.mediaValidation({type: 'media'}),
|
|
245
|
+
http(api.media.upload)
|
|
246
|
+
);
|
|
247
|
+
|
|
239
248
|
// ## Invites
|
|
240
249
|
router.get('/invites', mw.authAdminApi, http(api.invites.browse));
|
|
241
250
|
router.get('/invites/:id', mw.authAdminApi, http(api.invites.read));
|
|
@@ -31,23 +31,30 @@ const messages = {
|
|
|
31
31
|
icons: {
|
|
32
32
|
missingFile: 'Please select an icon.',
|
|
33
33
|
invalidFile: 'Icon must be a square .ico or .png file between 60px – 1,000px, under 100kb.'
|
|
34
|
+
},
|
|
35
|
+
media: {
|
|
36
|
+
missingFile: 'Please select a media file.',
|
|
37
|
+
invalidFile: 'Please select a valid media file.'
|
|
38
|
+
},
|
|
39
|
+
thumbnail: {
|
|
40
|
+
missingFile: 'Please select a thumbnail.',
|
|
41
|
+
invalidFile: 'Please select a valid thumbnail.'
|
|
34
42
|
}
|
|
35
43
|
};
|
|
36
44
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
multer: multer({dest: os.tmpdir()})
|
|
40
|
-
};
|
|
45
|
+
const enabledClear = config.get('uploadClear') || true;
|
|
46
|
+
const upload = multer({dest: os.tmpdir()});
|
|
41
47
|
|
|
42
48
|
const deleteSingleFile = file => fs.unlink(file.path).catch(err => logging.error(err));
|
|
43
49
|
|
|
44
50
|
const single = name => (req, res, next) => {
|
|
45
|
-
const singleUpload = upload.
|
|
51
|
+
const singleUpload = upload.single(name);
|
|
52
|
+
|
|
46
53
|
singleUpload(req, res, (err) => {
|
|
47
54
|
if (err) {
|
|
48
55
|
return next(err);
|
|
49
56
|
}
|
|
50
|
-
if (
|
|
57
|
+
if (enabledClear) {
|
|
51
58
|
const deleteFiles = () => {
|
|
52
59
|
res.removeListener('finish', deleteFiles);
|
|
53
60
|
res.removeListener('close', deleteFiles);
|
|
@@ -70,6 +77,43 @@ const single = name => (req, res, next) => {
|
|
|
70
77
|
});
|
|
71
78
|
};
|
|
72
79
|
|
|
80
|
+
const media = (fileName, thumbName) => (req, res, next) => {
|
|
81
|
+
const mediaUpload = upload.fields([{
|
|
82
|
+
name: fileName,
|
|
83
|
+
maxCount: 1
|
|
84
|
+
}, {
|
|
85
|
+
name: thumbName,
|
|
86
|
+
maxCount: 1
|
|
87
|
+
}]);
|
|
88
|
+
|
|
89
|
+
mediaUpload(req, res, (err) => {
|
|
90
|
+
if (err) {
|
|
91
|
+
return next(err);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (enabledClear) {
|
|
95
|
+
const deleteFiles = () => {
|
|
96
|
+
res.removeListener('finish', deleteFiles);
|
|
97
|
+
res.removeListener('close', deleteFiles);
|
|
98
|
+
if (!req.disableUploadClear) {
|
|
99
|
+
if (req.files.file) {
|
|
100
|
+
return req.files.file.forEach(deleteSingleFile);
|
|
101
|
+
}
|
|
102
|
+
if (req.files.thumbnail) {
|
|
103
|
+
return req.files.thumbnail.forEach(deleteSingleFile);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
if (!req.disableUploadClear) {
|
|
108
|
+
res.on('finish', deleteFiles);
|
|
109
|
+
res.on('close', deleteFiles);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
next();
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
73
117
|
const checkFileExists = (fileData) => {
|
|
74
118
|
return !!(fileData.mimetype && fileData.path);
|
|
75
119
|
};
|
|
@@ -84,9 +128,13 @@ const checkFileIsValid = (fileData, types, extensions) => {
|
|
|
84
128
|
return false;
|
|
85
129
|
};
|
|
86
130
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
131
|
+
/**
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} options
|
|
134
|
+
* @param {String} options.type - type of the file
|
|
135
|
+
* @returns {Function}
|
|
136
|
+
*/
|
|
137
|
+
const validation = function ({type}) {
|
|
90
138
|
// if we finish the data/importer logic, we forward the request to the specified importer
|
|
91
139
|
return function uploadValidation(req, res, next) {
|
|
92
140
|
const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
|
|
@@ -116,9 +164,68 @@ const validation = function (options) {
|
|
|
116
164
|
};
|
|
117
165
|
};
|
|
118
166
|
|
|
167
|
+
/**
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} options
|
|
170
|
+
* @param {String} options.type - type of the file
|
|
171
|
+
* @returns {Function}
|
|
172
|
+
*/
|
|
173
|
+
const mediaValidation = function ({type}) {
|
|
174
|
+
return function mediaUploadValidation(req, res, next) {
|
|
175
|
+
const extensions = (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
|
|
176
|
+
const contentTypes = (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || [];
|
|
177
|
+
|
|
178
|
+
const thumbnailExtensions = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.extensions) || [];
|
|
179
|
+
const thumbnailContentTypes = (config.get('uploads').thumbnails && config.get('uploads').thumbnails.contentTypes) || [];
|
|
180
|
+
|
|
181
|
+
const {file: [file] = []} = req.files;
|
|
182
|
+
if (!file || !checkFileExists(file)) {
|
|
183
|
+
return next(new errors.ValidationError({
|
|
184
|
+
message: tpl(messages[type].missingFile)
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
req.file = file;
|
|
189
|
+
req.file.name = req.file.originalname;
|
|
190
|
+
req.file.type = req.file.mimetype;
|
|
191
|
+
req.file.ext = path.extname(req.file.name).toLowerCase();
|
|
192
|
+
|
|
193
|
+
if (!checkFileIsValid(req.file, contentTypes, extensions)) {
|
|
194
|
+
return next(new errors.UnsupportedMediaTypeError({
|
|
195
|
+
message: tpl(messages[type].invalidFile, {extensions: extensions})
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const {thumbnail: [thumbnailFile] = []} = req.files;
|
|
200
|
+
|
|
201
|
+
if (thumbnailFile) {
|
|
202
|
+
if (!checkFileExists(thumbnailFile)) {
|
|
203
|
+
return next(new errors.ValidationError({
|
|
204
|
+
message: tpl(messages.thumbnail.missingFile)
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
req.thumbnail = thumbnailFile;
|
|
209
|
+
req.thumbnail.ext = path.extname(thumbnailFile.originalname).toLowerCase();
|
|
210
|
+
req.thumbnail.name = `${path.basename(req.file.name, path.extname(req.file.name))}_thumb${req.thumbnail.ext}`;
|
|
211
|
+
req.thumbnail.type = req.thumbnail.mimetype;
|
|
212
|
+
|
|
213
|
+
if (!checkFileIsValid(req.thumbnail, thumbnailContentTypes, thumbnailExtensions)) {
|
|
214
|
+
return next(new errors.UnsupportedMediaTypeError({
|
|
215
|
+
message: tpl(messages.thumbnail.invalidFile, {extensions: thumbnailExtensions})
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
next();
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
119
224
|
module.exports = {
|
|
120
225
|
single,
|
|
121
|
-
|
|
226
|
+
media,
|
|
227
|
+
validation,
|
|
228
|
+
mediaValidation
|
|
122
229
|
};
|
|
123
230
|
|
|
124
231
|
// Exports for testing only
|
|
@@ -37,7 +37,7 @@ module.exports = function setupMembersApp() {
|
|
|
37
37
|
membersApp.put('/api/member', bodyParser.json({limit: '1mb'}), middleware.updateMemberData);
|
|
38
38
|
membersApp.post('/api/member/email', bodyParser.json({limit: '1mb'}), (req, res) => membersService.api.middleware.updateEmailAddress(req, res));
|
|
39
39
|
membersApp.get('/api/session', middleware.getIdentityToken);
|
|
40
|
-
membersApp.get('/api/offers/:id',
|
|
40
|
+
membersApp.get('/api/offers/:id', middleware.getOfferData);
|
|
41
41
|
membersApp.delete('/api/session', middleware.deleteSession);
|
|
42
42
|
membersApp.get('/api/site', middleware.getMemberSiteData);
|
|
43
43
|
|
|
@@ -32,6 +32,8 @@ const getContentPath = function getContentPath(type) {
|
|
|
32
32
|
switch (type) {
|
|
33
33
|
case 'images':
|
|
34
34
|
return path.join(this.get('paths:contentPath'), 'images/');
|
|
35
|
+
case 'media':
|
|
36
|
+
return path.join(this.get('paths:contentPath'), 'media/');
|
|
35
37
|
case 'themes':
|
|
36
38
|
return path.join(this.get('paths:contentPath'), 'themes/');
|
|
37
39
|
case 'adapters':
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
"extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico", ".webp"],
|
|
31
31
|
"contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"]
|
|
32
32
|
},
|
|
33
|
+
"media": {
|
|
34
|
+
"extensions": [".mp4",".webm", ".ogv"],
|
|
35
|
+
"contentTypes": ["video/mp4", "video/webm", "video/ogg"]
|
|
36
|
+
},
|
|
37
|
+
"thumbnails": {
|
|
38
|
+
"extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico", ".webp"],
|
|
39
|
+
"contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "image/webp"]
|
|
40
|
+
},
|
|
33
41
|
"icons": {
|
|
34
42
|
"extensions": [".png", ".ico"],
|
|
35
43
|
"contentTypes": ["image/png", "image/x-icon", "image/vnd.microsoft.icon"]
|
package/core/shared/labs.js
CHANGED
|
@@ -15,8 +15,7 @@ const messages = {
|
|
|
15
15
|
|
|
16
16
|
// flags in this list always return `true`, allows quick global enable prior to full flag removal
|
|
17
17
|
const GA_FEATURES = [
|
|
18
|
-
'customThemeSettings'
|
|
19
|
-
'offers'
|
|
18
|
+
'customThemeSettings'
|
|
20
19
|
];
|
|
21
20
|
|
|
22
21
|
// NOTE: this allowlist is meant to be used to filter out any unexpected
|
|
@@ -28,7 +27,10 @@ const BETA_FEATURES = [
|
|
|
28
27
|
|
|
29
28
|
const ALPHA_FEATURES = [
|
|
30
29
|
'oauthLogin',
|
|
31
|
-
'membersActivity'
|
|
30
|
+
'membersActivity',
|
|
31
|
+
'cardSettingsPanel',
|
|
32
|
+
'mediaAPI',
|
|
33
|
+
'membersAutoLogin'
|
|
32
34
|
];
|
|
33
35
|
|
|
34
36
|
module.exports.GA_KEYS = [...GA_FEATURES];
|