/** * @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com> * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with OX App Suite. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>. * * Any use of the work other than as authorized under this license or copyright law is prohibited. */ import nodePath from 'node:path' import { promisify } from 'node:util' import zlib from 'node:zlib' import * as cache from './cache.js' import { configMap } from './config_map.js' import { NotAllowedOriginError, NotFoundError, VersionMismatchError, isVersionMismatchError } from './errors.js' import logger from './logger.js' import { getCSSDependenciesFor, getViteManifests } from './manifests.js' import { getVersionInfo } from './version.js' const gzip = promisify(zlib.gzip) const brotliCompress = promisify(zlib.brotliCompress) const compressFileSize = Number(process.env.COMPRESS_FILE_SIZE) const compressionMimeTypes = (process.env.COMPRESS_FILE_TYPES || '').replace(/([.+*?^$()[\]{}|])/g, '\\$1').split(' ') const compressionWhitelistRegex = new RegExp(`^(${compressionMimeTypes.join('|')})($|;)`, 'i') export async function fetchFileWithHeadersFromBaseUrl ({ path, baseUrl, version }) { const upstreamUrl = new URL(path, baseUrl) if (configMap.origins.includes(upstreamUrl.origin) === false) { logger.debug(`"${upstreamUrl.origin}" does not match valid baseUrl: "${baseUrl}".`) throw new NotAllowedOriginError('This origin is not allowed', { status: 400 }) } const [response, dependencies] = await Promise.all([ fetch(upstreamUrl, { cache: 'no-store' }), nodePath.extname(path) === '.js' && getCSSDependenciesFor({ file: path.substr(1), version }) ]) if (!response.ok) { if (response.status === 404) logger.trace(`[Files] "${path}" could not be found on "${baseUrl}". Responded with: ${response.status}`) else logger.error(`[Files] Unexpected result from file retrieval "${path}" on "${baseUrl}", responded with: ${response.status}`) throw new NotFoundError(`Error fetching file: ${path}`, { status: response.status }) } if (response.headers.get('version')) { const requestedVersion = (await getVersionInfo()).details[baseUrl] const receivedVersion = response.headers.get('version') if (requestedVersion !== receivedVersion) { throw new VersionMismatchError(`${path} does not contain the right version. Needs ${requestedVersion} but received ${receivedVersion}`) } } const result = { body: Buffer.from(await response.arrayBuffer()), headers: { 'content-type': response.headers.get('content-type') === 'application/javascript' ? 'application/javascript; charset=utf-8' : response.headers.get('content-type'), dependencies } } if (result.body.length > compressFileSize && compressionWhitelistRegex.test(result.headers['content-type'])) { if (path === '/index.html') { result.body = await gzip(result.body) result.headers['content-encoding'] = 'gzip' } else { result.body = await brotliCompress(result.body) result.headers['content-encoding'] = 'br' } } return result } export async function fetchFileWithHeaders ({ path, version }) { const viteManifests = await getViteManifests({ version }) const module = viteManifests[path.substr(1)] if (module?.meta?.baseUrl) { const baseUrl = module?.meta?.baseUrl try { return fetchFileWithHeadersFromBaseUrl({ path, baseUrl, version }) } catch (err) { logger.debug(`[Files] File ${path} had a baseUrl but could not be found on that server: ${err}`) if (isVersionMismatchError(err)) throw err } } return Promise.any(configMap.urls.map(baseUrl => fetchFileWithHeadersFromBaseUrl({ path, baseUrl, version }))) } export function getFile ({ version, path, fetchFiles = true }) { return cache.getFile({ name: path, version }, ({ name: path, version }) => { if (!fetchFiles) throw new Error('[File] Not found in cache: ' + path) return fetchFileWithHeaders({ version, path }).catch((err) => { if (!isVersionMismatchError(err)) throw err logger.warn(`[Files] The file ${path} has been delivered with the wrong version from the UI container.`) throw err }) }) } export async function warmCache ({ version, fetchFiles = false }) { const start = +new Date() logger.info('[File] start warming up the cache') const viteManifests = {} try { Object.assign(viteManifests, await getViteManifests({ version })) } catch (err) {} const paths = Object.keys(viteManifests).map(key => `/${viteManifests[key].file}`) while (paths.length > 0) { await Promise.all(paths.splice(0, 20).map(path => { return getFile({ version, path, fetchFiles }) })) } logger.info(`[File] finished warming up the cache in ${Math.floor((+new Date() - start) / 1000)}s`) }