mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
🔒 Fully separated front-end and admin app urls
no issue - uses `vhost` in parent-app to properly split front-end and admin/api apps when a separate admin url is configured
This commit is contained in:
parent
58b9aea00d
commit
b46f9b1dc2
6 changed files with 552 additions and 17 deletions
|
@ -1,9 +1,16 @@
|
|||
const debug = require('ghost-ignition').debug('web:parent');
|
||||
const express = require('express');
|
||||
const vhost = require('vhost');
|
||||
const config = require('../config');
|
||||
const compress = require('compression');
|
||||
const netjet = require('netjet');
|
||||
const shared = require('./shared');
|
||||
const escapeRegExp = require('lodash.escaperegexp');
|
||||
const {URL} = require('url');
|
||||
const urlUtils = require('../lib/url-utils');
|
||||
const storage = require('../adapters/storage');
|
||||
|
||||
const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`;
|
||||
|
||||
module.exports = function setupParentApp(options = {}) {
|
||||
debug('ParentApp setup start');
|
||||
|
@ -37,17 +44,31 @@ module.exports = function setupParentApp(options = {}) {
|
|||
// This sets global res.locals which are needed everywhere
|
||||
parentApp.use(shared.middlewares.ghostLocals);
|
||||
|
||||
// Wrap the admin and API apps into a single express app for use with vhost
|
||||
const adminApp = express();
|
||||
adminApp.enable('trust proxy'); // required to respect x-forwarded-proto in admin requests
|
||||
adminApp.use('/ghost/api', require('./api')());
|
||||
adminApp.use('/ghost', require('./admin')());
|
||||
// TODO: remove /content/* once we're sure the API is not returning relative asset URLs anywhere
|
||||
adminApp.use(STATIC_IMAGE_URL_PREFIX, shared.middlewares.image.handleImageSizes, storage.getStorage().serve());
|
||||
|
||||
// Mount the apps on the parentApp
|
||||
|
||||
// API
|
||||
// @TODO: finish refactoring the API app
|
||||
parentApp.use('/ghost/api', require('./api')());
|
||||
const adminHost = config.get('admin:url') ? (new URL(config.get('admin:url')).hostname) : '';
|
||||
const frontendHost = new URL(config.get('url')).hostname;
|
||||
const hasSeparateAdmin = adminHost && adminHost !== frontendHost;
|
||||
|
||||
// ADMIN
|
||||
parentApp.use('/ghost', require('./admin')());
|
||||
// ADMIN + API
|
||||
// with a separate admin url only serve on that host, otherwise serve on all hosts
|
||||
const adminVhostArg = hasSeparateAdmin && adminHost ? adminHost : /.*/;
|
||||
parentApp.use(vhost(adminVhostArg, adminApp));
|
||||
|
||||
// BLOG
|
||||
parentApp.use(require('./site')(options));
|
||||
// with a separate admin url we adjust the frontend vhost to exclude requests to that host, otherwise serve on all hosts
|
||||
const frontendVhostArg = (hasSeparateAdmin && adminHost) ?
|
||||
new RegExp(`^(?!${escapeRegExp(adminHost)}).*`) : /.*/;
|
||||
|
||||
parentApp.use(vhost(frontendVhostArg, require('./site')(options)));
|
||||
|
||||
debug('ParentApp setup end');
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ module.exports = function adminRedirects() {
|
|||
router.get(/^\/(logout|signout)\/$/, adminRedirect('#/signout/'));
|
||||
router.get(/^\/signup\/$/, adminRedirect('#/signup/'));
|
||||
// redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc.
|
||||
router.get(/^\/((ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/'));
|
||||
router.get(/^\/((ghost|ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
|
@ -5202,4 +5202,458 @@ describe('Integration - Web - Site', function () {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent app vhosts', function () {
|
||||
describe('no separate admin', function () {
|
||||
before(function () {
|
||||
testUtils.integrationTesting.urlService.resetGenerators();
|
||||
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
|
||||
testUtils.integrationTesting.overrideGhostConfig(configUtils);
|
||||
|
||||
configUtils.set('url', 'http://example.com');
|
||||
configUtils.set('admin:url', null);
|
||||
|
||||
return testUtils.integrationTesting.initGhost()
|
||||
.then(function () {
|
||||
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
|
||||
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
|
||||
|
||||
app = siteApp({start: true});
|
||||
return testUtils.integrationTesting.urlService.waitTillFinished();
|
||||
})
|
||||
.then(() => {
|
||||
return appsService.init();
|
||||
});
|
||||
});
|
||||
|
||||
before(function () {
|
||||
configUtils.set('url', 'http://example.com');
|
||||
configUtils.set('admin:url', null);
|
||||
urlUtils.stubUrlUtilsFromConfig();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
configUtils.restore();
|
||||
urlUtils.restore();
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('loads the front-end on configured url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the front-end on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the admin', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the admin on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the api', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the api on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('separate admin host', function () {
|
||||
before(function () {
|
||||
testUtils.integrationTesting.urlService.resetGenerators();
|
||||
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
|
||||
testUtils.integrationTesting.overrideGhostConfig(configUtils);
|
||||
|
||||
configUtils.set('url', 'http://example.com');
|
||||
configUtils.set('admin:url', 'https://admin.example.com');
|
||||
|
||||
return testUtils.integrationTesting.initGhost()
|
||||
.then(function () {
|
||||
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
|
||||
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
|
||||
|
||||
app = siteApp({start: true});
|
||||
return testUtils.integrationTesting.urlService.waitTillFinished();
|
||||
})
|
||||
.then(() => {
|
||||
return appsService.init();
|
||||
});
|
||||
});
|
||||
|
||||
before(function () {
|
||||
urlUtils.stubUrlUtilsFromConfig();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
configUtils.restore();
|
||||
urlUtils.restore();
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('loads the front-end on configured url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the front-end on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects /ghost/ on configured url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://admin.example.com/ghost/');
|
||||
});
|
||||
});
|
||||
|
||||
it('404s the api on configured url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(404);
|
||||
});
|
||||
});
|
||||
|
||||
it('404s the api on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(404);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the admin on configured admin url', function () {
|
||||
const req = {
|
||||
secure: true,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'admin.example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the api on configured admin url', function () {
|
||||
const req = {
|
||||
secure: true,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'admin.example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to the correct protocol on configured admin url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'admin.example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://admin.example.com/ghost/');
|
||||
});
|
||||
});
|
||||
|
||||
it('404s the front-end on configured admin url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'admin.example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('same host separate protocol', function () {
|
||||
before(function () {
|
||||
testUtils.integrationTesting.urlService.resetGenerators();
|
||||
testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true});
|
||||
testUtils.integrationTesting.overrideGhostConfig(configUtils);
|
||||
|
||||
configUtils.set('url', 'http://example.com');
|
||||
configUtils.set('admin:url', 'https://example.com');
|
||||
|
||||
return testUtils.integrationTesting.initGhost()
|
||||
.then(function () {
|
||||
sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2');
|
||||
sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2);
|
||||
|
||||
app = siteApp({start: true});
|
||||
return testUtils.integrationTesting.urlService.waitTillFinished();
|
||||
})
|
||||
.then(() => {
|
||||
return appsService.init();
|
||||
});
|
||||
});
|
||||
|
||||
before(function () {
|
||||
urlUtils.stubUrlUtilsFromConfig();
|
||||
});
|
||||
|
||||
it('loads the front-end on configured url (http)', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('404s the front-end on configured url (https)', function () {
|
||||
const req = {
|
||||
secure: true,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the front-end on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects /ghost/ on configured url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://example.com/ghost/');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects /ghost/ on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://example.com/ghost/');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects api to correct protocol on configured admin url', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the admin on configured admin url', function () {
|
||||
const req = {
|
||||
secure: true,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects the admin on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://example.com/ghost/');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the api on configured admin url', function () {
|
||||
const req = {
|
||||
secure: true,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'example.com'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects the api on localhost', function () {
|
||||
const req = {
|
||||
secure: false,
|
||||
method: 'GET',
|
||||
url: '/ghost/api/v2/admin/site/',
|
||||
host: 'localhost'
|
||||
};
|
||||
|
||||
return testUtils.mocks.express.invoke(app, req)
|
||||
.then(function (response) {
|
||||
response.statusCode.should.eql(301);
|
||||
response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
var should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
proxyquire = require('proxyquire');
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
const proxyquire = require('proxyquire');
|
||||
const configUtils = require('../../utils/configUtils');
|
||||
|
||||
describe('parent app', function () {
|
||||
let expressStub;
|
||||
let vhostSpy;
|
||||
let use;
|
||||
let apiSpy;
|
||||
let parentApp;
|
||||
|
@ -19,6 +21,7 @@ describe('parent app', function () {
|
|||
enable: () => {}
|
||||
});
|
||||
|
||||
vhostSpy = sinon.spy();
|
||||
apiSpy = sinon.spy();
|
||||
adminSpy = sinon.spy();
|
||||
siteSpy = sinon.spy();
|
||||
|
@ -27,6 +30,7 @@ describe('parent app', function () {
|
|||
|
||||
parentApp = proxyquire('../../../server/web/parent-app', {
|
||||
express: expressStub,
|
||||
vhost: vhostSpy,
|
||||
'./api': apiSpy,
|
||||
'./admin': adminSpy,
|
||||
'./site': siteSpy,
|
||||
|
@ -35,20 +39,70 @@ describe('parent app', function () {
|
|||
authPages: authPagesSpy
|
||||
}
|
||||
});
|
||||
|
||||
configUtils.set('url', 'http://ghost.blog');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
configUtils.restore();
|
||||
});
|
||||
|
||||
it('should mount 3 apps and assign correct routes to them', function () {
|
||||
parentApp();
|
||||
// url = 'https://ghost.blog'
|
||||
describe('without separate admin url', function () {
|
||||
it('should mount and assign correct routes', function () {
|
||||
parentApp();
|
||||
|
||||
use.calledWith('/ghost/api').should.be.true();
|
||||
use.calledWith('/ghost').should.be.true();
|
||||
use.calledWith('/ghost/api').should.be.true();
|
||||
use.calledWith('/ghost').should.be.true();
|
||||
use.calledWith('/content/images').should.be.true();
|
||||
|
||||
apiSpy.called.should.be.true();
|
||||
adminSpy.called.should.be.true();
|
||||
siteSpy.called.should.be.true();
|
||||
apiSpy.called.should.be.true();
|
||||
adminSpy.called.should.be.true();
|
||||
siteSpy.called.should.be.true();
|
||||
|
||||
vhostSpy.calledTwice.should.be.true();
|
||||
vhostSpy.firstCall.calledWith(/.*/).should.be.true();
|
||||
vhostSpy.secondCall.calledWith(/.*/).should.be.true();
|
||||
});
|
||||
});
|
||||
|
||||
// url = 'https://ghost.blog'
|
||||
// admin.url = 'https://admin.ghost.blog'
|
||||
describe('with separate admin url', function () {
|
||||
beforeEach(function () {
|
||||
configUtils.set('admin:url', 'https://admin.ghost.blog');
|
||||
});
|
||||
|
||||
it('should mount and assign correct routes', function () {
|
||||
parentApp();
|
||||
|
||||
vhostSpy.calledTwice.should.be.true();
|
||||
vhostSpy.firstCall.calledWith('admin.ghost.blog').should.be.true();
|
||||
vhostSpy.secondCall.calledWith(/^(?!admin\.ghost\.blog).*/).should.be.true();
|
||||
});
|
||||
|
||||
it('should have regex that excludes admin traffic on front-end', function () {
|
||||
parentApp();
|
||||
const frontendRegex = vhostSpy.secondCall.args[0];
|
||||
|
||||
frontendRegex.test('localhost').should.be.true();
|
||||
frontendRegex.test('ghost.blog').should.be.true();
|
||||
frontendRegex.test('admin.ghost.blog').should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
// url = 'http://ghost.blog'
|
||||
// admin.url = 'https://ghost.blog'
|
||||
describe('with separate admin protocol', function () {
|
||||
it('should mount and assign correct routes', function () {
|
||||
configUtils.set('admin:url', 'https://ghost.blog');
|
||||
|
||||
parentApp();
|
||||
|
||||
vhostSpy.calledTwice.should.be.true();
|
||||
vhostSpy.firstCall.calledWith(/.*/).should.be.true();
|
||||
vhostSpy.secondCall.calledWith(/.*/).should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -127,6 +127,7 @@
|
|||
"simple-html-tokenizer": "0.5.8",
|
||||
"uuid": "3.3.3",
|
||||
"validator": "6.3.0",
|
||||
"vhost": "3.0.2",
|
||||
"xml": "1.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
|
@ -8623,6 +8623,11 @@ verror@1.10.0:
|
|||
core-util-is "1.0.2"
|
||||
extsprintf "^1.2.0"
|
||||
|
||||
vhost@3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/vhost/-/vhost-3.0.2.tgz#2fb1decd4c466aa88b0f9341af33dc1aff2478d5"
|
||||
integrity sha1-L7HezUxGaqiLD5NBrzPcGv8keNU=
|
||||
|
||||
video-extensions@~1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/video-extensions/-/video-extensions-1.1.0.tgz#eaa86b45f29a853c2b873e9d8e23b513712997d6"
|
||||
|
|
Loading…
Add table
Reference in a new issue