- Download a CSV file of all your post data for easy analysis in one place
+ Download a CSV file of your last 1000 posts data for easy analysis in one place
-
+
diff --git a/ghost/admin/app/components/settings/analytics.js b/ghost/admin/app/components/settings/analytics.js
index c7f40b0625..c6ee17e30f 100644
--- a/ghost/admin/app/components/settings/analytics.js
+++ b/ghost/admin/app/components/settings/analytics.js
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
+import {task} from 'ember-concurrency';
export default class Analytics extends Component {
@service settings;
@@ -16,13 +17,15 @@ export default class Analytics extends Component {
this.settings.emailTrackOpens = !this.settings.emailTrackOpens;
}
- @action
- exportData() {
+ @task
+ *exportPostsTask() {
let exportUrl = ghostPaths().url.api('posts/export');
let downloadParams = new URLSearchParams();
- downloadParams.set('limit', 'all');
+ downloadParams.set('limit', 1000);
- this.utils.downloadFile(`${exportUrl}?${downloadParams.toString()}`);
+ yield this.utils.fetchAndDownloadFile(`${exportUrl}?${downloadParams.toString()}`);
+
+ return true;
}
@action
diff --git a/ghost/admin/app/services/utils.js b/ghost/admin/app/services/utils.js
index a173c38002..4a02af6ab8 100644
--- a/ghost/admin/app/services/utils.js
+++ b/ghost/admin/app/services/utils.js
@@ -1,4 +1,5 @@
import Service from '@ember/service';
+import fetch from 'fetch';
export default class UtilsService extends Service {
downloadFile(url) {
@@ -14,6 +15,29 @@ export default class UtilsService extends Service {
iframe.setAttribute('src', url);
}
+ /**
+ * This method will fetch a file from the server and then download it, resolving
+ * once the initial fetch is complete, allowing it to be used with loading spinners.
+ *
+ * @param {string} url - The URL of the file to download
+ * @returns {Promise}
+ */
+ async fetchAndDownloadFile(url) {
+ const response = await fetch(url);
+ const blob = await response.blob();
+
+ const anchor = document.createElement('a');
+
+ anchor.href = window.URL.createObjectURL(blob);
+ anchor.download = /filename="(.*)"/.exec(response.headers.get('Content-Disposition'))[1];
+
+ document.body.append(anchor);
+
+ anchor.click();
+
+ document.body.removeChild(anchor);
+ }
+
/**
* Remove tracking parameters from a URL
* @param {string} url
diff --git a/ghost/admin/package.json b/ghost/admin/package.json
index f0e59cbdf7..ac983e8a21 100644
--- a/ghost/admin/package.json
+++ b/ghost/admin/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
- "version": "5.42.0",
+ "version": "5.42.1",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
diff --git a/ghost/core/core/frontend/web/middleware/static-theme.js b/ghost/core/core/frontend/web/middleware/static-theme.js
index 4066c92a1d..e2a6d393fa 100644
--- a/ghost/core/core/frontend/web/middleware/static-theme.js
+++ b/ghost/core/core/frontend/web/middleware/static-theme.js
@@ -14,15 +14,45 @@ function isDeniedFile(file) {
return deniedFiles.includes(base) || deniedFileTypes.includes(ext);
}
+/**
+ * Copy from:
+ * https://github.com/pillarjs/send/blob/b69cbb3dc4c09c37917d08a4c13fcd1bac97ade5/index.js#L987-L1003
+ *
+ * Allows V8 to only deoptimize this fn instead of all
+ * of send().
+ *
+ * @param {string} filePath
+ * @returns {string|number} returns -1 number if decode decodeURIComponent throws
+ */
+function decode(filePath) {
+ try {
+ return decodeURIComponent(filePath);
+ } catch (err) {
+ return -1;
+ }
+}
+
+/**
+ *
+ * @param {string} file path to a requested file
+ * @returns {boolean}
+ */
function isAllowedFile(file) {
+ const decodedFilePath = decode(file);
+ if (decodedFilePath === -1) {
+ return false;
+ }
+
+ const normalizedFilePath = path.normalize(decodedFilePath);
+
const allowedFiles = ['manifest.json'];
const allowedPath = '/assets/';
const alwaysDeny = ['.hbs'];
- const ext = path.extname(file);
- const base = path.basename(file);
+ const ext = path.extname(normalizedFilePath);
+ const base = path.basename(normalizedFilePath);
- return allowedFiles.includes(base) || (file.startsWith(allowedPath) && !alwaysDeny.includes(ext));
+ return allowedFiles.includes(base) || (normalizedFilePath.startsWith(allowedPath) && !alwaysDeny.includes(ext));
}
function forwardToExpressStatic(req, res, next) {
diff --git a/ghost/core/package.json b/ghost/core/package.json
index c359ddc314..1a0c17e9fa 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost",
- "version": "5.42.0",
+ "version": "5.42.1",
"description": "The professional publishing platform",
"author": "Ghost Foundation",
"homepage": "https://ghost.org",
diff --git a/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js b/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
index 24e7303c7c..7fabf13b1e 100644
--- a/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
+++ b/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
@@ -184,4 +184,28 @@ describe('staticTheme', function () {
done();
});
});
+
+ it('should disallow path traversal', function (done) {
+ req.path = '/assets/built%2F..%2F..%2F/package.json';
+ req.method = 'GET';
+
+ staticTheme()(req, res, function next() {
+ activeThemeStub.called.should.be.false();
+ expressStaticStub.called.should.be.false();
+
+ done();
+ });
+ });
+
+ it('should not crash when malformatted URL sequence is passed', function (done) {
+ req.path = '/assets/built%2F..%2F..%2F%E0%A4%A/package.json';
+ req.method = 'GET';
+
+ staticTheme()(req, res, function next() {
+ activeThemeStub.called.should.be.false();
+ expressStaticStub.called.should.be.false();
+
+ done();
+ });
+ });
});