mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 03:42:27 +03:00
🐛 Fixed perms error when building public assets
closes: https://github.com/TryGhost/Ghost/issues/13739 - Ghost cannot write to the core folder in correctly configured production installations - Built assets therefore need to be written to the content directory - Ghost does not overwrite anything in the content folder as part of an upgrade, therefore static files that are provided by Ghost must still live inside /core - So as a result, we now have core/frontend/public and content/public
This commit is contained in:
parent
c784d7068c
commit
592d02fd23
3
.gitignore
vendored
3
.gitignore
vendored
@ -106,6 +106,7 @@ projectFilesBackup
|
|||||||
/content/images/**/*
|
/content/images/**/*
|
||||||
/content/media/**/*
|
/content/media/**/*
|
||||||
/content/files/**/*
|
/content/files/**/*
|
||||||
|
/content/public/*
|
||||||
/content/adapters/storage/**/*
|
/content/adapters/storage/**/*
|
||||||
/content/adapters/scheduling/**/*
|
/content/adapters/scheduling/**/*
|
||||||
!/content/themes/casper
|
!/content/themes/casper
|
||||||
@ -125,8 +126,6 @@ test/coverage
|
|||||||
# Built asset files
|
# Built asset files
|
||||||
/core/built
|
/core/built
|
||||||
/core/server/web/admin/views/*.html
|
/core/server/web/admin/views/*.html
|
||||||
/core/frontend/public/*.min.css
|
|
||||||
/core/frontend/public/*.min.js
|
|
||||||
|
|
||||||
# Caddyfile - for local development with ssl + caddy
|
# Caddyfile - for local development with ssl + caddy
|
||||||
Caddyfile
|
Caddyfile
|
||||||
|
@ -13,6 +13,8 @@ content/adapters/**
|
|||||||
!content/adapters/README.md
|
!content/adapters/README.md
|
||||||
content/apps/**
|
content/apps/**
|
||||||
!content/apps/README.md
|
!content/apps/README.md
|
||||||
|
content/public/**
|
||||||
|
!content/public/README.md
|
||||||
content/data/**
|
content/data/**
|
||||||
!content/data/README.md
|
!content/data/README.md
|
||||||
content/images/**
|
content/images/**
|
||||||
|
@ -75,7 +75,6 @@ module.exports = function (grunt) {
|
|||||||
'core/shared/**/*.js',
|
'core/shared/**/*.js',
|
||||||
'core/frontend/**/*.js',
|
'core/frontend/**/*.js',
|
||||||
'core/frontend/src/**/*.css',
|
'core/frontend/src/**/*.css',
|
||||||
'!core/frontend/public/**',
|
|
||||||
'core/*.js',
|
'core/*.js',
|
||||||
'index.js',
|
'index.js',
|
||||||
'config.*.json',
|
'config.*.json',
|
||||||
|
3
content/public/README.md
Normal file
3
content/public/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Content / Public
|
||||||
|
|
||||||
|
Ghost will store any built assets here. This goes hand in hand with core/frontend/public where static assets are stored.
|
@ -3,12 +3,12 @@ const _ = require('lodash');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const logging = require('@tryghost/logging');
|
const logging = require('@tryghost/logging');
|
||||||
|
const config = require('../../../shared/config');
|
||||||
|
|
||||||
class CardAssetService {
|
class CardAssetService {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
// @TODO: use our config paths concept
|
this.src = options.src || path.join(config.get('paths').assetSrc, 'cards');
|
||||||
this.src = options.src || path.join(__dirname, '../../src/cards');
|
this.dest = options.dest || config.getContentPath('public');
|
||||||
this.dest = options.dest || path.join(__dirname, '../../public');
|
|
||||||
this.minifier = new Minifier({src: this.src, dest: this.dest});
|
this.minifier = new Minifier({src: this.src, dest: this.dest});
|
||||||
|
|
||||||
if ('config' in options) {
|
if ('config' in options) {
|
||||||
@ -90,12 +90,12 @@ class CardAssetService {
|
|||||||
/**
|
/**
|
||||||
* A theme can declare which cards it supports, and we'll do the rest
|
* A theme can declare which cards it supports, and we'll do the rest
|
||||||
*
|
*
|
||||||
* @param {Array|boolean} config
|
* @param {Array|boolean} cardAssetConfig
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async load(config) {
|
async load(cardAssetConfig) {
|
||||||
if (config) {
|
if (cardAssetConfig) {
|
||||||
this.config = config;
|
this.config = cardAssetConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.clearFiles();
|
await this.clearFiles();
|
||||||
|
@ -11,10 +11,16 @@ const messages = {
|
|||||||
fileNotFound: 'File not found'
|
fileNotFound: 'File not found'
|
||||||
};
|
};
|
||||||
|
|
||||||
function createPublicFileMiddleware(file, type, maxAge) {
|
function createPublicFileMiddleware(location, file, mime, maxAge) {
|
||||||
let content;
|
let content;
|
||||||
const publicFilePath = config.get('paths').publicFilePath;
|
// These files are provided by Ghost, and therefore live inside of the core folder
|
||||||
const filePath = file.match(/^public/) ? path.join(publicFilePath, file.replace(/^public/, '')) : path.join(publicFilePath, file);
|
const staticFilePath = config.get('paths').publicFilePath;
|
||||||
|
// These files are built on the fly, and must be saved in the content folder
|
||||||
|
const builtFilePath = config.getContentPath('public');
|
||||||
|
|
||||||
|
let locationPath = location === 'static' ? staticFilePath : builtFilePath;
|
||||||
|
|
||||||
|
const filePath = file.match(/^public/) ? path.join(locationPath, file.replace(/^public/, '')) : path.join(locationPath, file);
|
||||||
const blogRegex = /(\{\{blog-url\}\})/g;
|
const blogRegex = /(\{\{blog-url\}\})/g;
|
||||||
|
|
||||||
return function servePublicFileMiddleware(req, res, next) {
|
return function servePublicFileMiddleware(req, res, next) {
|
||||||
@ -24,7 +30,7 @@ function createPublicFileMiddleware(file, type, maxAge) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send image files directly and let express handle content-length, etag, etc
|
// send image files directly and let express handle content-length, etag, etc
|
||||||
if (type.match(/^image/)) {
|
if (mime.match(/^image/)) {
|
||||||
return res.sendFile(filePath, (err) => {
|
return res.sendFile(filePath, (err) => {
|
||||||
if (err && err.status === 404) {
|
if (err && err.status === 404) {
|
||||||
// ensure we're triggering basic asset 404 and not a templated 404
|
// ensure we're triggering basic asset 404 and not a templated 404
|
||||||
@ -57,13 +63,13 @@ function createPublicFileMiddleware(file, type, maxAge) {
|
|||||||
|
|
||||||
let str = buf.toString();
|
let str = buf.toString();
|
||||||
|
|
||||||
if (type === 'text/xsl' || type === 'text/plain' || type === 'application/javascript') {
|
if (mime === 'text/xsl' || mime === 'text/plain' || mime === 'application/javascript') {
|
||||||
str = str.replace(blogRegex, urlUtils.urlFor('home', true).replace(/\/$/, ''));
|
str = str.replace(blogRegex, urlUtils.urlFor('home', true).replace(/\/$/, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
content = {
|
content = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': type,
|
'Content-Type': mime,
|
||||||
'Content-Length': Buffer.from(str).length,
|
'Content-Length': Buffer.from(str).length,
|
||||||
ETag: `"${crypto.createHash('md5').update(str, 'utf8').digest('hex')}"`,
|
ETag: `"${crypto.createHash('md5').update(str, 'utf8').digest('hex')}"`,
|
||||||
'Cache-Control': `public, max-age=${maxAge}`
|
'Cache-Control': `public, max-age=${maxAge}`
|
||||||
@ -78,8 +84,8 @@ function createPublicFileMiddleware(file, type, maxAge) {
|
|||||||
|
|
||||||
// ### servePublicFile Middleware
|
// ### servePublicFile Middleware
|
||||||
// Handles requests to robots.txt and favicon.ico (and caches them)
|
// Handles requests to robots.txt and favicon.ico (and caches them)
|
||||||
function servePublicFile(file, type, maxAge) {
|
function servePublicFile(location, file, type, maxAge) {
|
||||||
const publicFileMiddleware = createPublicFileMiddleware(file, type, maxAge);
|
const publicFileMiddleware = createPublicFileMiddleware(location, file, type, maxAge);
|
||||||
|
|
||||||
return function servePublicFileMiddleware(req, res, next) {
|
return function servePublicFileMiddleware(req, res, next) {
|
||||||
if (req.path === '/' + file) {
|
if (req.path === '/' + file) {
|
||||||
|
@ -104,15 +104,15 @@ module.exports = function setupSiteApp(options = {}) {
|
|||||||
siteApp.use(mw.serveFavicon());
|
siteApp.use(mw.serveFavicon());
|
||||||
|
|
||||||
// Serve sitemap.xsl file
|
// Serve sitemap.xsl file
|
||||||
siteApp.use(mw.servePublicFile('sitemap.xsl', 'text/xsl', constants.ONE_DAY_S));
|
siteApp.use(mw.servePublicFile('static', 'sitemap.xsl', 'text/xsl', constants.ONE_DAY_S));
|
||||||
|
|
||||||
// Serve stylesheets for default templates
|
// Serve stylesheets for default templates
|
||||||
siteApp.use(mw.servePublicFile('public/ghost.css', 'text/css', constants.ONE_HOUR_S));
|
siteApp.use(mw.servePublicFile('static', 'public/ghost.css', 'text/css', constants.ONE_HOUR_S));
|
||||||
siteApp.use(mw.servePublicFile('public/ghost.min.css', 'text/css', constants.ONE_YEAR_S));
|
siteApp.use(mw.servePublicFile('static', 'public/ghost.min.css', 'text/css', constants.ONE_YEAR_S));
|
||||||
|
|
||||||
// Card assets
|
// Card assets
|
||||||
siteApp.use(mw.servePublicFile('public/cards.min.css', 'text/css', constants.ONE_YEAR_S));
|
siteApp.use(mw.servePublicFile('built', 'public/cards.min.css', 'text/css', constants.ONE_YEAR_S));
|
||||||
siteApp.use(mw.servePublicFile('public/cards.min.js', 'text/js', constants.ONE_YEAR_S));
|
siteApp.use(mw.servePublicFile('built', 'public/cards.min.js', 'text/js', constants.ONE_YEAR_S));
|
||||||
|
|
||||||
// Serve blog images using the storage adapter
|
// Serve blog images using the storage adapter
|
||||||
siteApp.use(STATIC_IMAGE_URL_PREFIX, mw.handleImageSizes, storage.getStorage('images').serve());
|
siteApp.use(STATIC_IMAGE_URL_PREFIX, mw.handleImageSizes, storage.getStorage('images').serve());
|
||||||
@ -147,7 +147,7 @@ module.exports = function setupSiteApp(options = {}) {
|
|||||||
debug('Themes done');
|
debug('Themes done');
|
||||||
|
|
||||||
// Serve robots.txt if not found in theme
|
// Serve robots.txt if not found in theme
|
||||||
siteApp.use(mw.servePublicFile('robots.txt', 'text/plain', constants.ONE_HOUR_S));
|
siteApp.use(mw.servePublicFile('static', 'robots.txt', 'text/plain', constants.ONE_HOUR_S));
|
||||||
|
|
||||||
// site map - this should probably be refactored to be an internal app
|
// site map - this should probably be refactored to be an internal app
|
||||||
sitemapHandler(siteApp);
|
sitemapHandler(siteApp);
|
||||||
|
@ -16,7 +16,8 @@
|
|||||||
"useMinFiles": true,
|
"useMinFiles": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"contentPath": "content/",
|
"contentPath": "content/",
|
||||||
"fixtures": "core/server/data/schema/fixtures/fixtures"
|
"fixtures": "core/server/data/schema/fixtures/fixtures",
|
||||||
|
"assetSrc": "core/frontend/src"
|
||||||
},
|
},
|
||||||
"adapters": {
|
"adapters": {
|
||||||
"sso": {
|
"sso": {
|
||||||
|
@ -84,6 +84,8 @@ const getContentPath = function getContentPath(type) {
|
|||||||
return path.join(this.get('paths:contentPath'), 'data/');
|
return path.join(this.get('paths:contentPath'), 'data/');
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return path.join(this.get('paths:contentPath'), 'settings/');
|
return path.join(this.get('paths:contentPath'), 'settings/');
|
||||||
|
case 'public':
|
||||||
|
return path.join(this.get('paths:contentPath'), 'public/');
|
||||||
default:
|
default:
|
||||||
// new Error is allowed here, as we do not want config to depend on @tryghost/error
|
// new Error is allowed here, as we do not want config to depend on @tryghost/error
|
||||||
// @TODO: revisit this decision when @tryghost/error is no longer dependent on all of ghost-ignition
|
// @TODO: revisit this decision when @tryghost/error is no longer dependent on all of ghost-ignition
|
||||||
|
@ -73,6 +73,19 @@ describe('Card Asset Service', function () {
|
|||||||
cardAssets.files.should.eql(['cards.min.css']);
|
cardAssets.files.should.eql(['cards.min.css']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can correctly load nothing when config is false', async function () {
|
||||||
|
const cardAssets = new CardAssetService({
|
||||||
|
src: srcDir,
|
||||||
|
dest: destDir
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(srcDir, 'css', 'test.css'), '.test { color: #fff }');
|
||||||
|
|
||||||
|
await cardAssets.load(false);
|
||||||
|
|
||||||
|
cardAssets.files.should.eql([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('can clearFiles', async function () {
|
it('can clearFiles', async function () {
|
||||||
const cardAssets = new CardAssetService({
|
const cardAssets = new CardAssetService({
|
||||||
src: srcDir,
|
src: srcDir,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
const should = require('should');
|
const should = require('should');
|
||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
const servePublicFile = require('../../../../../core/frontend/web/middleware/serve-public-file');
|
const servePublicFile = require('../../../../../core/frontend/web/middleware/serve-public-file');
|
||||||
|
|
||||||
describe('servePublicFile', function () {
|
describe('servePublicFile', function () {
|
||||||
@ -19,20 +20,20 @@ describe('servePublicFile', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return a middleware', function () {
|
it('should return a middleware', function () {
|
||||||
const result = servePublicFile('robots.txt', 'text/plain', 3600);
|
const result = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
|
|
||||||
result.should.be.a.Function();
|
result.should.be.a.Function();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if the request does NOT match the file', function () {
|
it('should skip if the request does NOT match the file', function () {
|
||||||
const middleware = servePublicFile('robots.txt', 'text/plain', 3600);
|
const middleware = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
req.path = '/favicon.ico';
|
req.path = '/favicon.ico';
|
||||||
middleware(req, res, next);
|
middleware(req, res, next);
|
||||||
next.called.should.be.true();
|
next.called.should.be.true();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load the file and send it', function () {
|
it('should load the file and send it', function () {
|
||||||
const middleware = servePublicFile('robots.txt', 'text/plain', 3600);
|
const middleware = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
const body = 'User-agent: * Disallow: /';
|
const body = 'User-agent: * Disallow: /';
|
||||||
req.path = '/robots.txt';
|
req.path = '/robots.txt';
|
||||||
|
|
||||||
@ -58,11 +59,11 @@ describe('servePublicFile', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should send the correct headers', function () {
|
it('should send the correct headers', function () {
|
||||||
const middleware = servePublicFile('robots.txt', 'text/plain', 3600);
|
const middleware = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
const body = 'User-agent: * Disallow: /';
|
const body = 'User-agent: * Disallow: /';
|
||||||
req.path = '/robots.txt';
|
req.path = '/robots.txt';
|
||||||
|
|
||||||
sinon.stub(fs, 'readFile').callsFake(function (file, cb) {
|
let fileStub = sinon.stub(fs, 'readFile').callsFake(function (file, cb) {
|
||||||
cb(null, body);
|
cb(null, body);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,7 +73,9 @@ describe('servePublicFile', function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
middleware(req, res, next);
|
middleware(req, res, next);
|
||||||
|
|
||||||
next.called.should.be.false();
|
next.called.should.be.false();
|
||||||
|
fileStub.firstCall.args[0].should.endWith('core/frontend/public/robots.txt');
|
||||||
res.writeHead.called.should.be.true();
|
res.writeHead.called.should.be.true();
|
||||||
res.writeHead.args[0][0].should.equal(200);
|
res.writeHead.args[0][0].should.equal(200);
|
||||||
res.writeHead.calledWith(200, sinon.match.has('Content-Type')).should.be.true();
|
res.writeHead.calledWith(200, sinon.match.has('Content-Type')).should.be.true();
|
||||||
@ -82,7 +85,7 @@ describe('servePublicFile', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should replace {{blog-url}} in text/plain', function () {
|
it('should replace {{blog-url}} in text/plain', function () {
|
||||||
const middleware = servePublicFile('robots.txt', 'text/plain', 3600);
|
const middleware = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
const body = 'User-agent: {{blog-url}}';
|
const body = 'User-agent: {{blog-url}}';
|
||||||
req.path = '/robots.txt';
|
req.path = '/robots.txt';
|
||||||
|
|
||||||
@ -103,7 +106,7 @@ describe('servePublicFile', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should 404 for ENOENT on general files', function () {
|
it('should 404 for ENOENT on general files', function () {
|
||||||
const middleware = servePublicFile('robots.txt', 'text/plain', 3600);
|
const middleware = servePublicFile('static', 'robots.txt', 'text/plain', 3600);
|
||||||
req.path = '/robots.txt';
|
req.path = '/robots.txt';
|
||||||
|
|
||||||
sinon.stub(fs, 'readFile').callsFake(function (file, cb) {
|
sinon.stub(fs, 'readFile').callsFake(function (file, cb) {
|
||||||
@ -122,4 +125,27 @@ describe('servePublicFile', function () {
|
|||||||
next.called.should.be.true();
|
next.called.should.be.true();
|
||||||
next.calledWith(sinon.match({errorType: 'NotFoundError', code: 'PUBLIC_FILE_NOT_FOUND'})).should.be.true();
|
next.calledWith(sinon.match({errorType: 'NotFoundError', code: 'PUBLIC_FILE_NOT_FOUND'})).should.be.true();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can serve a built asset file as well as public files', function () {
|
||||||
|
const middleware = servePublicFile('built', 'something.css', 'text/css', 3600);
|
||||||
|
const body = '.foo {bar: baz}';
|
||||||
|
req.path = '/something.css';
|
||||||
|
|
||||||
|
let fileStub = sinon.stub(fs, 'readFile').callsFake(function (file, cb) {
|
||||||
|
cb(null, body);
|
||||||
|
});
|
||||||
|
|
||||||
|
res = {
|
||||||
|
writeHead: sinon.spy(),
|
||||||
|
end: sinon.spy()
|
||||||
|
};
|
||||||
|
|
||||||
|
middleware(req, res, next);
|
||||||
|
|
||||||
|
next.called.should.be.false();
|
||||||
|
res.writeHead.called.should.be.true();
|
||||||
|
res.writeHead.args[0][0].should.equal(200);
|
||||||
|
|
||||||
|
fileStub.firstCall.args[0].should.endWith('content/public/something.css');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -96,6 +96,7 @@ describe('Config Loader', function () {
|
|||||||
Object.keys(pathConfig).should.eql([
|
Object.keys(pathConfig).should.eql([
|
||||||
'contentPath',
|
'contentPath',
|
||||||
'fixtures',
|
'fixtures',
|
||||||
|
'assetSrc',
|
||||||
'appRoot',
|
'appRoot',
|
||||||
'corePath',
|
'corePath',
|
||||||
'clientAssets',
|
'clientAssets',
|
||||||
|
Loading…
Reference in New Issue
Block a user