Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/volto/src/config/server.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import imagesMiddleware from '@plone/volto/express-middleware/images';
import blocksSchemaMiddleware from '@plone/volto/express-middleware/blocks-schema';
import filesMiddleware from '@plone/volto/express-middleware/files';
import robotstxtMiddleware from '@plone/volto/express-middleware/robotstxt';
import sitemapMiddleware from '@plone/volto/express-middleware/sitemap';
Expand All @@ -8,6 +9,7 @@ import devProxyMiddleware from '@plone/volto/express-middleware/devproxy';
const settings = {
expressMiddleware: [
devProxyMiddleware(),
blocksSchemaMiddleware(),
filesMiddleware(),
imagesMiddleware(),
robotstxtMiddleware(),
Expand Down
93 changes: 93 additions & 0 deletions packages/volto/src/express-middleware/blocks-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import express from 'express';
import { createIntl, createIntlCache } from 'react-intl';

import config from '@plone/volto/registry';
import { getRequestAuthToken } from '@plone/volto/helpers/AuthToken/AuthToken';

const intlCache = createIntlCache();

function createUnauthorizedError() {
return {
status: 401,
title: 'Unauthorized',
detail: 'Authentication credentials were not provided or are invalid.',
};
}

function getIntl(res) {
const intl = res.locals?.store?.getState?.().intl || {};

return createIntl(
{
locale: intl.locale || 'en',
messages: intl.messages || {},
},
intlCache,
);
}

function resolveSchema(schemaFactory, blockConfig, intl) {
if (!schemaFactory) {
return null;
}

if (typeof schemaFactory !== 'function') {
return schemaFactory;
}

return schemaFactory({
data: { '@type': blockConfig.id },
intl,
props: {
data: { '@type': blockConfig.id },
},
});
}

async function ensureAuthenticated(req, res) {
void res;
const token = getRequestAuthToken(req);
if (!token) {
throw createUnauthorizedError();
}
}

async function blocksSchema(req, res, next) {
try {
await ensureAuthenticated(req, res);
const intl = getIntl(res);
const blocksConfig = config.blocks.blocksConfig || {};
const items = Object.values(blocksConfig).map((blockConfig) => ({
id: blockConfig.id,
title: blockConfig.title,
schema: resolveSchema(blockConfig.blockSchema, blockConfig, intl),
}));

res.set('Cache-Control', 'private, max-age=0, must-revalidate');
res.json({
'@id': req.originalUrl || req.url,
items,
items_total: items.length,
});
} catch (error) {
if (error?.status === 401) {
res.set('WWW-Authenticate', 'Bearer');
res.status(401).json({
error: {
type: error.title,
message: error.detail,
},
});
return;
}

next(error);
}
}

export default function blocksSchemaMiddleware() {
const middleware = express.Router();
middleware.all('/@blocks-schema', blocksSchema);
middleware.id = 'blocksSchema';
return middleware;
}
159 changes: 159 additions & 0 deletions packages/volto/src/express-middleware/blocks-schema.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import config from '@plone/volto/registry';
import blocksSchemaMiddleware from '@plone/volto/express-middleware/blocks-schema';

function parseCookies(cookieHeader = '') {
return Object.fromEntries(
cookieHeader
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [key, ...rest] = item.split('=');
return [key, rest.join('=')];
}),
);
}

async function invokeMiddleware({ headers = {} }) {
const cookies = parseCookies(headers.Cookie || headers.cookie || '');
const middleware = blocksSchemaMiddleware();

return await new Promise((resolve, reject) => {
const req = {
method: 'GET',
url: '/@blocks-schema',
originalUrl: '/@blocks-schema',
headers,
universalCookies: {
get: (name) => cookies[name],
},
};
const res = {
locals: {
store: {
getState: () => ({
intl: {
locale: 'en',
messages: {},
},
}),
},
},
headers: {},
statusCode: 200,
set(name, value) {
this.headers[name.toLowerCase()] = value;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
resolve({
status: this.statusCode,
headers: this.headers,
body,
});
return this;
},
};

middleware(req, res, (error) => {
if (error) {
reject(error);
return;
}

reject(new Error('Middleware completed without a response.'));
});
});
}

describe('/@blocks-schema middleware', () => {
const originalBlocks = config.blocks;

beforeEach(() => {
config.set('blocks', {
...originalBlocks,
blocksConfig: {
alpha: {
id: 'alpha',
title: 'Alpha',
blockSchema: ({ intl }) => ({
title: intl.formatMessage({
id: 'alpha-schema',
defaultMessage: 'Alpha schema',
}),
fieldsets: [],
properties: {},
required: [],
}),
},
beta: {
id: 'beta',
title: 'Beta',
blockSchema: null,
},
},
});
});

afterEach(() => {
config.set('blocks', originalBlocks);
});

it('returns 401 when no auth token is provided', async () => {
const response = await invokeMiddleware({});

expect(response.status).toBe(401);
expect(response.headers['www-authenticate']).toBe('Bearer');
expect(response.body).toEqual({
error: {
type: 'Unauthorized',
message: 'Authentication credentials were not provided or are invalid.',
},
});
});

it('accepts Authorization bearer tokens', async () => {
const response = await invokeMiddleware({
headers: {
authorization: 'Bearer test-token',
},
});

expect(response.status).toBe(200);
expect(response.body).toEqual({
'@id': '/@blocks-schema',
items: [
{
id: 'alpha',
title: 'Alpha',
schema: {
title: 'Alpha schema',
fieldsets: [],
properties: {},
required: [],
},
},
{
id: 'beta',
title: 'Beta',
schema: null,
},
],
items_total: 2,
});
});

it('accepts auth_token cookies', async () => {
const response = await invokeMiddleware({
headers: {
cookie: 'auth_token=cookie-token',
},
});

expect(response.status).toBe(200);
});
});
3 changes: 2 additions & 1 deletion packages/volto/src/helpers/Api/APIResourceWithAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import superagent from 'superagent';
import config from '@plone/volto/registry';
import { getRequestAuthToken } from '@plone/volto/helpers/AuthToken/AuthToken';
import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
import { stripSubpathPrefix } from '@plone/volto/helpers/Url/Url';

Expand Down Expand Up @@ -35,7 +36,7 @@ export const getAPIResourceWithAuth = (req) =>
.get(`${apiPath}${__DEVELOPMENT__ ? '' : apiSuffix}${contentPath}`)
.maxResponseSize(settings.maxResponseSize)
.responseType('blob');
const authToken = req.universalCookies.get('auth_token');
const authToken = getRequestAuthToken(req);
if (authToken) {
request.set('Authorization', `Bearer ${authToken}`);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/volto/src/helpers/Api/Api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import superagent from 'superagent';
import Cookies from 'universal-cookie';
import config from '@plone/volto/registry';
import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
import { getRequestAuthToken } from '@plone/volto/helpers/AuthToken/AuthToken';
import {
stripQuerystring,
stripSubpathPrefix,
Expand Down Expand Up @@ -73,7 +74,7 @@ class Api {
let authToken;
if (req) {
// We are in SSR
authToken = req.universalCookies.get('auth_token');
authToken = getRequestAuthToken(req);
request.use(addHeadersFactory(req));
} else {
authToken = cookies.get('auth_token');
Expand Down
18 changes: 18 additions & 0 deletions packages/volto/src/helpers/AuthToken/AuthToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ export function getAuthToken() {
return cookies.get('auth_token');
}

/**
* Get auth token from a server request.
* Authorization header takes precedence over the auth_token cookie.
* @method getRequestAuthToken
* @param {object} req Server request.
* @returns {string|undefined} The auth token.
*/
export function getRequestAuthToken(req) {
const authorization = req?.headers?.authorization;
const [, token] = authorization?.match(/^Bearer\s+(.+)$/i) || [];

if (token) {
return token;
}

return req?.universalCookies?.get('auth_token');
}

/**
* Persist auth token method.
* @method persistAuthToken
Expand Down
1 change: 1 addition & 0 deletions packages/volto/types/express-middleware/blocks-schema.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function blocksSchemaMiddleware(): any;
Loading