From 7dfc5dc6afa342909179b2835c7fccb4f300a045 Mon Sep 17 00:00:00 2001 From: Nicola Krumschmidt Date: Thu, 26 Aug 2021 23:11:21 +0200 Subject: [PATCH] Use root-relative base url for app and extensions (#6923) * Add Url util class * Use relative base url for app and extensions Also use utils/url when working with PUBLIC_URL in other places. --- api/src/app.ts | 15 ++++--- api/src/extensions.ts | 6 ++- api/src/services/mail/index.ts | 14 +++--- api/src/services/users.ts | 9 ++-- api/src/utils/url.ts | 78 ++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 api/src/utils/url.ts diff --git a/api/src/app.ts b/api/src/app.ts index b902c987ae..110616adce 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -45,15 +45,13 @@ import { validateStorage } from './utils/validate-storage'; import { register as registerWebhooks } from './webhooks'; import { session } from './middleware/session'; import { flushCaches } from './cache'; -import { URL } from 'url'; +import { Url } from './utils/url'; export default async function createApp(): Promise { validateEnv(['KEY', 'SECRET']); - try { - new URL(env.PUBLIC_URL); - } catch { - logger.warn('PUBLIC_URL is not a valid URL'); + if (!new Url(env.PUBLIC_URL).isAbsolute()) { + logger.warn('PUBLIC_URL should be a full URL'); } await validateStorage(); @@ -126,11 +124,14 @@ export default async function createApp(): Promise { if (env.SERVE_APP) { const adminPath = require.resolve('@directus/app/dist/index.html'); - const publicUrl = env.PUBLIC_URL.endsWith('/') ? env.PUBLIC_URL : env.PUBLIC_URL + '/'; + const adminUrl = new Url(env.PUBLIC_URL).addPath('admin'); // Set the App's base path according to the APIs public URL let html = fse.readFileSync(adminPath, 'utf-8'); - html = html.replace(//, `\n\t\t`); + html = html.replace( + //, + `\n\t\t` + ); app.get('/admin', (req, res) => res.send(html)); app.use('/admin', express.static(path.join(adminPath, '..'))); diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 1b1dcebf02..9344e5ad22 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -32,6 +32,7 @@ import { rollup } from 'rollup'; // @ts-expect-error import virtual from '@rollup/plugin-virtual'; import alias from '@rollup/plugin-alias'; +import { Url } from './utils/url'; let extensions: Extension[] = []; let extensionBundles: Partial> = {}; @@ -120,14 +121,15 @@ async function generateExtensionBundles() { async function getSharedDepsMapping(deps: string[]) { const appDir = await fse.readdir(path.join(resolvePackage('@directus/app'), 'dist')); - const adminUrl = env.PUBLIC_URL.endsWith('/') ? env.PUBLIC_URL + 'admin' : env.PUBLIC_URL + '/admin'; const depsMapping: Record = {}; for (const dep of deps) { const depName = appDir.find((file) => dep.replace(/\//g, '_') === file.substring(0, file.indexOf('.'))); if (depName) { - depsMapping[dep] = `${adminUrl}/${depName}`; + const depUrl = new Url(env.PUBLIC_URL).addPath('admin', depName); + + depsMapping[dep] = depUrl.toString({ rootRelative: true }); } else { logger.warn(`Couldn't find shared extension dependency "${dep}"`); } diff --git a/api/src/services/mail/index.ts b/api/src/services/mail/index.ts index b02a4c90ce..8a990961fc 100644 --- a/api/src/services/mail/index.ts +++ b/api/src/services/mail/index.ts @@ -11,6 +11,7 @@ import { Accountability } from '@directus/shared/types'; import getMailer from '../../mailer'; import { Transporter, SendMailOptions } from 'nodemailer'; import prettier from 'prettier'; +import { Url } from '../../utils/url'; const liquidEngine = new Liquid({ root: [path.resolve(env.EXTENSIONS_PATH, 'templates'), path.resolve(__dirname, 'templates')], @@ -100,16 +101,15 @@ export class MailService { }; function getProjectLogoURL(logoID?: string) { - let projectLogoURL = env.PUBLIC_URL; - if (projectLogoURL.endsWith('/') === false) { - projectLogoURL += '/'; - } + const projectLogoUrl = new Url(env.PUBLIC_URL); + if (logoID) { - projectLogoURL += `assets/${logoID}`; + projectLogoUrl.addPath('assets', logoID); } else { - projectLogoURL += `admin/img/directus-white.png`; + projectLogoUrl.addPath('admin', 'img', 'directus-white.png'); } - return projectLogoURL; + + return projectLogoUrl.toString(); } } } diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 0a90249ca9..e0a2e95c82 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -17,6 +17,7 @@ import { AbstractServiceOptions, Item, PrimaryKey, Query, SchemaOverview } from import { Accountability } from '@directus/shared/types'; import isUrlAllowed from '../utils/is-url-allowed'; import { toArray } from '@directus/shared/utils'; +import { Url } from '../utils/url'; import { AuthenticationService } from './authentication'; import { ItemsService, MutationOptions } from './items'; import { MailService } from './mail'; @@ -305,9 +306,9 @@ export class UsersService extends ItemsService { const payload = { email, scope: 'invite' }; const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d' }); - const inviteURL = url ?? env.PUBLIC_URL + '/admin/accept-invite'; - const acceptURL = inviteURL + '?token=' + token; - const subjectLine = subject ? subject : "You've been invited"; + const subjectLine = subject ?? "You've been invited"; + const inviteURL = url ? new Url(url) : new Url(env.PUBLIC_URL).addPath('admin', 'accept-invite'); + inviteURL.setQuery('token', token); await mailService.send({ to: email, @@ -315,7 +316,7 @@ export class UsersService extends ItemsService { template: { name: 'user-invitation', data: { - url: acceptURL, + url: inviteURL.toString(), email, }, }, diff --git a/api/src/utils/url.ts b/api/src/utils/url.ts new file mode 100644 index 0000000000..a084a26757 --- /dev/null +++ b/api/src/utils/url.ts @@ -0,0 +1,78 @@ +import { URL } from 'url'; + +export class Url { + protocol: string | null; + host: string | null; + port: string | null; + path: string[]; + query: Record; + hash: string | null; + + constructor(url: string) { + const parsedUrl = new URL(url, 'http://localhost'); + + const isProtocolRelative = /^\/\//.test(url); + const isRootRelative = /^\/$|^\/[^/]/.test(url); + const isPathRelative = /^\./.test(url); + + this.protocol = + !isProtocolRelative && !isRootRelative && !isPathRelative + ? parsedUrl.protocol.substring(0, parsedUrl.protocol.length - 1) + : null; + this.host = !isRootRelative && !isPathRelative ? parsedUrl.host : null; + this.port = parsedUrl.port !== '' ? parsedUrl.port : null; + this.path = parsedUrl.pathname.split('/').filter((p) => p !== ''); + this.query = Object.fromEntries(parsedUrl.searchParams.entries()); + this.hash = parsedUrl.hash !== '' ? parsedUrl.hash.substring(1) : null; + } + + public isAbsolute(): boolean { + return this.protocol !== null && this.host !== null; + } + + public isProtocolRelative(): boolean { + return this.protocol === null && this.host !== null; + } + + public isRootRelative(): boolean { + return this.protocol === null && this.host === null; + } + + public addPath(...paths: string[]): Url { + const pathToAdd = paths.flatMap((p) => p.split('/')).filter((p) => p !== ''); + + for (const pathSegment of pathToAdd) { + if (pathSegment === '..') { + this.path.pop(); + } else if (pathSegment !== '.') { + this.path.push(pathSegment); + } + } + + return this; + } + + public setQuery(key: string, value: string): Url { + this.query[key] = value; + + return this; + } + + public toString({ rootRelative } = { rootRelative: false }): string { + const protocol = this.protocol !== null ? `${this.protocol}:` : ''; + const host = this.host ?? ''; + const port = this.port !== null ? `:${this.port}` : ''; + const origin = `${this.host !== null ? `${protocol}//` : ''}${host}${port}`; + + const path = `/${this.path.join('/')}`; + const query = + Object.keys(this.query).length !== 0 + ? `?${Object.entries(this.query) + .map(([k, v]) => `${k}=${v}`) + .join('&')}` + : ''; + const hash = this.hash !== null ? `#${this.hash}` : ''; + + return `${!rootRelative ? origin : ''}${path}${query}${hash}`; + } +}