diff --git a/packages/volto/src/config/server.js b/packages/volto/src/config/server.js index 167c48910b7..eb8bcf1fd8e 100644 --- a/packages/volto/src/config/server.js +++ b/packages/volto/src/config/server.js @@ -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'; @@ -8,6 +9,7 @@ import devProxyMiddleware from '@plone/volto/express-middleware/devproxy'; const settings = { expressMiddleware: [ devProxyMiddleware(), + blocksSchemaMiddleware(), filesMiddleware(), imagesMiddleware(), robotstxtMiddleware(), diff --git a/packages/volto/src/express-middleware/blocks-schema.js b/packages/volto/src/express-middleware/blocks-schema.js new file mode 100644 index 00000000000..d506ee1ecba --- /dev/null +++ b/packages/volto/src/express-middleware/blocks-schema.js @@ -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; +} diff --git a/packages/volto/src/express-middleware/blocks-schema.test.js b/packages/volto/src/express-middleware/blocks-schema.test.js new file mode 100644 index 00000000000..e61c2cd1b63 --- /dev/null +++ b/packages/volto/src/express-middleware/blocks-schema.test.js @@ -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); + }); +}); diff --git a/packages/volto/src/helpers/Api/APIResourceWithAuth.js b/packages/volto/src/helpers/Api/APIResourceWithAuth.js index 666e1c2804d..c018d547c11 100644 --- a/packages/volto/src/helpers/Api/APIResourceWithAuth.js +++ b/packages/volto/src/helpers/Api/APIResourceWithAuth.js @@ -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'; @@ -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}`); } diff --git a/packages/volto/src/helpers/Api/Api.js b/packages/volto/src/helpers/Api/Api.js index 9dd7c72710a..a739da91bc2 100644 --- a/packages/volto/src/helpers/Api/Api.js +++ b/packages/volto/src/helpers/Api/Api.js @@ -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, @@ -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'); diff --git a/packages/volto/src/helpers/AuthToken/AuthToken.js b/packages/volto/src/helpers/AuthToken/AuthToken.js index 9d01deb0a6d..b55223c396a 100644 --- a/packages/volto/src/helpers/AuthToken/AuthToken.js +++ b/packages/volto/src/helpers/AuthToken/AuthToken.js @@ -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 diff --git a/packages/volto/types/express-middleware/blocks-schema.d.ts b/packages/volto/types/express-middleware/blocks-schema.d.ts new file mode 100644 index 00000000000..6727774ad3d --- /dev/null +++ b/packages/volto/types/express-middleware/blocks-schema.d.ts @@ -0,0 +1 @@ +export default function blocksSchemaMiddleware(): any;