From 41e425756d43c361b6890634aa97f0da8322e8ec Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Tue, 31 May 2022 18:48:23 -0700 Subject: [PATCH] chore: refactor domain parsing to be more general (#3448) * change the api of domain parsing to just parseDomain and getCookieDomain * adds getBaseDomain as the method to get the domain after any official subdomains --- app/components/Authenticated.tsx | 6 +- app/scenes/Document/components/Document.tsx | 4 +- app/scenes/Login/index.tsx | 4 +- app/scenes/Settings/Details.tsx | 5 +- app/stores/AuthStore.ts | 2 +- app/utils/domains.ts | 14 -- server/models/Team.ts | 5 +- server/routes/api/auth.ts | 17 +- server/routes/auth/providers/email.test.ts | 13 +- server/routes/auth/providers/email.ts | 18 +- server/utils/authentication.ts | 2 +- server/utils/domains.ts | 15 -- server/utils/passport.ts | 2 +- shared/utils/domains.test.ts | 205 +++++++++++--------- shared/utils/domains.ts | 113 +++++------ shared/utils/urls.ts | 28 +-- 16 files changed, 216 insertions(+), 237 deletions(-) delete mode 100644 app/utils/domains.ts delete mode 100644 server/utils/domains.ts diff --git a/app/components/Authenticated.tsx b/app/components/Authenticated.tsx index 976063b50..e0fc321b3 100644 --- a/app/components/Authenticated.tsx +++ b/app/components/Authenticated.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Redirect } from "react-router-dom"; -import { isCustomSubdomain } from "@shared/utils/domains"; +import { parseDomain } from "@shared/utils/domains"; import LoadingIndicator from "~/components/LoadingIndicator"; import env from "~/env"; import useStores from "~/hooks/useStores"; @@ -40,9 +40,7 @@ const Authenticated = ({ children }: Props) => { } } else if ( env.SUBDOMAINS_ENABLED && - team.subdomain && - isCustomSubdomain(hostname) && - !hostname.startsWith(`${team.subdomain}.`) + parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "") ) { window.location.href = `${team.url}${window.location.pathname}`; return ; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 15d40dfa7..f934aca1b 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -15,6 +15,7 @@ import { import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { Heading } from "@shared/editor/lib/getHeadings"; +import { parseDomain } from "@shared/utils/domains"; import getTasks from "@shared/utils/getTasks"; import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; @@ -33,7 +34,6 @@ import withStores from "~/components/withStores"; import type { Editor as TEditor } from "~/editor"; import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; -import { isCustomDomain } from "~/utils/domains"; import { emojiToUrl } from "~/utils/emoji"; import { isModKey } from "~/utils/keyboard"; import { @@ -630,7 +630,7 @@ class DocumentScene extends React.Component { - {isShare && !isCustomDomain() && ( + {isShare && !parseDomain(window.location.origin).custom && ( )} diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index 4b0ad47c3..6401eca1b 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -6,6 +6,7 @@ import { Trans, useTranslation } from "react-i18next"; import { useLocation, Link, Redirect } from "react-router-dom"; import styled from "styled-components"; import { getCookie, setCookie } from "tiny-cookie"; +import { parseDomain } from "@shared/utils/domains"; import { Config } from "~/stores/AuthStore"; import ButtonLarge from "~/components/ButtonLarge"; import Fade from "~/components/Fade"; @@ -20,7 +21,6 @@ import Text from "~/components/Text"; import env from "~/env"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; -import { isCustomDomain } from "~/utils/domains"; import isCloudHosted from "~/utils/isCloudHosted"; import { changeLanguage, detectLanguage } from "~/utils/language"; import AuthenticationProvider from "./AuthenticationProvider"; @@ -30,7 +30,7 @@ function Header({ config }: { config?: Config | undefined }) { const { t } = useTranslation(); const isSubdomain = !!config?.hostname; - if (!isCloudHosted || isCustomDomain()) { + if (!isCloudHosted || parseDomain(window.location.origin).custom) { return null; } diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index e763b083f..26a608343 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -3,6 +3,7 @@ import { TeamIcon } from "outline-icons"; import { useRef, useState } from "react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; +import { getBaseDomain } from "@shared/utils/domains"; import Button from "~/components/Button"; import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect"; import Heading from "~/components/Heading"; @@ -141,7 +142,9 @@ function Details() { subdomain ? ( <> Your knowledge base will be accessible at{" "} - {subdomain}.getoutline.com + + {subdomain}.{getBaseDomain()} + ) : ( t("Choose a subdomain to enable a login page just for your team.") diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index aedfb4536..1e4873a84 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react"; import invariant from "invariant"; import { observable, action, computed, autorun, runInAction } from "mobx"; import { getCookie, setCookie, removeCookie } from "tiny-cookie"; +import { getCookieDomain } from "@shared/utils/domains"; import RootStore from "~/stores/RootStore"; import Policy from "~/models/Policy"; import Team from "~/models/Team"; @@ -9,7 +10,6 @@ import User from "~/models/User"; import env from "~/env"; import { client } from "~/utils/ApiClient"; import Storage from "~/utils/Storage"; -import { getCookieDomain } from "~/utils/domains"; const AUTH_STORE = "AUTH_STORE"; const NO_REDIRECT_PATHS = ["/", "/create", "/home"]; diff --git a/app/utils/domains.ts b/app/utils/domains.ts deleted file mode 100644 index 0459bafef..000000000 --- a/app/utils/domains.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { parseDomain, stripSubdomain } from "@shared/utils/domains"; -import env from "~/env"; - -export function getCookieDomain(domain: string) { - return env.SUBDOMAINS_ENABLED ? stripSubdomain(domain) : domain; -} - -export function isCustomDomain() { - const parsed = parseDomain(window.location.origin); - const main = parseDomain(env.URL); - return ( - parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld) - ); -} diff --git a/server/models/Team.ts b/server/models/Team.ts index 69331063c..bf94eefc3 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -19,7 +19,7 @@ import { DataType, } from "sequelize-typescript"; import { v4 as uuidv4 } from "uuid"; -import { stripSubdomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; +import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import env from "@server/env"; import Logger from "@server/logging/Logger"; import { generateAvatarUrl } from "@server/utils/avatars"; @@ -122,6 +122,7 @@ class Team extends ParanoidModel { } get url() { + // custom domain if (this.domain) { return `https://${this.domain}`; } @@ -131,7 +132,7 @@ class Team extends ParanoidModel { } const url = new URL(env.URL); - url.host = `${this.subdomain}.${stripSubdomain(url.host)}`; + url.host = `${this.subdomain}.${getBaseDomain()}`; return url.href.replace(/\/$/, ""); } diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index 1172e7fd6..56daeb3a3 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -1,12 +1,11 @@ import invariant from "invariant"; import Router from "koa-router"; import { find } from "lodash"; -import { parseDomain, isCustomSubdomain } from "@shared/utils/domains"; +import { parseDomain } from "@shared/utils/domains"; import env from "@server/env"; import auth from "@server/middlewares/authentication"; import { Team, TeamDomain } from "@server/models"; import { presentUser, presentTeam, presentPolicies } from "@server/presenters"; -import { isCustomDomain } from "@server/utils/domains"; import providers from "../auth/providers"; const router = new Router(); @@ -55,7 +54,9 @@ router.post("auth.config", async (ctx) => { } } - if (isCustomDomain(ctx.request.hostname)) { + const domain = parseDomain(ctx.request.hostname); + + if (domain.custom) { const team = await Team.scope("withAuthenticationProviders").findOne({ where: { domain: ctx.request.hostname, @@ -76,16 +77,10 @@ router.post("auth.config", async (ctx) => { // If subdomain signin page then we return minimal team details to allow // for a custom screen showing only relevant signin options for that team. - if ( - env.SUBDOMAINS_ENABLED && - isCustomSubdomain(ctx.request.hostname) && - !isCustomDomain(ctx.request.hostname) - ) { - const domain = parseDomain(ctx.request.hostname); - const subdomain = domain ? domain.subdomain : undefined; + else if (env.SUBDOMAINS_ENABLED && domain.teamSubdomain) { const team = await Team.scope("withAuthenticationProviders").findOne({ where: { - subdomain, + subdomain: domain.teamSubdomain, }, }); diff --git a/server/routes/auth/providers/email.test.ts b/server/routes/auth/providers/email.test.ts index 3000e3648..a2090fdbb 100644 --- a/server/routes/auth/providers/email.test.ts +++ b/server/routes/auth/providers/email.test.ts @@ -1,4 +1,5 @@ import TestServer from "fetch-test-server"; +import sharedEnv from "@shared/env"; import SigninEmail from "@server/emails/templates/SigninEmail"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; import env from "@server/env"; @@ -41,8 +42,8 @@ describe("email", () => { }); it("should respond with redirect location when user is SSO enabled on another subdomain", async () => { - env.URL = "http://localoutline.com"; - env.SUBDOMAINS_ENABLED = true; + env.URL = sharedEnv.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true; const user = await buildUser(); const spy = jest.spyOn(WelcomeEmail, "schedule"); await buildTeam({ @@ -94,8 +95,8 @@ describe("email", () => { describe("with multiple users matching email", () => { it("should default to current subdomain with SSO", async () => { const spy = jest.spyOn(SigninEmail, "schedule"); - env.URL = "http://localoutline.com"; - env.SUBDOMAINS_ENABLED = true; + env.URL = sharedEnv.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true; const email = "sso-user@example.org"; const team = await buildTeam({ subdomain: "example", @@ -124,8 +125,8 @@ describe("email", () => { it("should default to current subdomain with guest email", async () => { const spy = jest.spyOn(SigninEmail, "schedule"); - env.URL = "http://localoutline.com"; - env.SUBDOMAINS_ENABLED = true; + env.URL = sharedEnv.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true; const email = "guest-user@example.org"; const team = await buildTeam({ subdomain: "example", diff --git a/server/routes/auth/providers/email.ts b/server/routes/auth/providers/email.ts index 30375571b..1cfac6714 100644 --- a/server/routes/auth/providers/email.ts +++ b/server/routes/auth/providers/email.ts @@ -1,7 +1,7 @@ import { subMinutes } from "date-fns"; import Router from "koa-router"; import { find } from "lodash"; -import { parseDomain, isCustomSubdomain } from "@shared/utils/domains"; +import { parseDomain } from "@shared/utils/domains"; import SigninEmail from "@server/emails/templates/SigninEmail"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; import env from "@server/env"; @@ -10,7 +10,6 @@ import errorHandling from "@server/middlewares/errorHandling"; import methodOverride from "@server/middlewares/methodOverride"; import { User, Team } from "@server/models"; import { signIn } from "@server/utils/authentication"; -import { isCustomDomain } from "@server/utils/domains"; import { getUserForEmailSigninToken } from "@server/utils/jwt"; import { assertEmail, assertPresent } from "@server/validation"; @@ -34,25 +33,18 @@ router.post("email", errorHandling(), async (ctx) => { if (users.length) { let team!: Team | null; + const domain = parseDomain(ctx.request.hostname); - if (isCustomDomain(ctx.request.hostname)) { + if (domain.custom) { team = await Team.scope("withAuthenticationProviders").findOne({ where: { domain: ctx.request.hostname, }, }); - } - - if ( - env.SUBDOMAINS_ENABLED && - isCustomSubdomain(ctx.request.hostname) && - !isCustomDomain(ctx.request.hostname) - ) { - const domain = parseDomain(ctx.request.hostname); - const subdomain = domain ? domain.subdomain : undefined; + } else if (env.SUBDOMAINS_ENABLED && domain.teamSubdomain) { team = await Team.scope("withAuthenticationProviders").findOne({ where: { - subdomain, + subdomain: domain.teamSubdomain, }, }); } diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index 6417b02a6..d28a8a0c3 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -2,10 +2,10 @@ import querystring from "querystring"; import { addMonths } from "date-fns"; import { Context } from "koa"; import { pick } from "lodash"; +import { getCookieDomain } from "@shared/utils/domains"; import env from "@server/env"; import Logger from "@server/logging/Logger"; import { User, Event, Team, Collection, View } from "@server/models"; -import { getCookieDomain } from "@server/utils/domains"; export async function signIn( ctx: Context, diff --git a/server/utils/domains.ts b/server/utils/domains.ts deleted file mode 100644 index 6793c952b..000000000 --- a/server/utils/domains.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { parseDomain, stripSubdomain } from "@shared/utils/domains"; -import env from "@server/env"; - -export function getCookieDomain(domain: string) { - return env.SUBDOMAINS_ENABLED ? stripSubdomain(domain) : domain; -} - -export function isCustomDomain(hostname: string) { - const parsed = parseDomain(hostname); - const main = parseDomain(env.URL); - - return ( - parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld) - ); -} diff --git a/server/utils/passport.ts b/server/utils/passport.ts index 2df44dc42..0eeefc73d 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -6,8 +6,8 @@ import { StateStoreStoreCallback, StateStoreVerifyCallback, } from "passport-oauth2"; +import { getCookieDomain } from "@shared/utils/domains"; import { OAuthStateMismatchError } from "../errors"; -import { getCookieDomain } from "./domains"; export class StateStore { key = "state"; diff --git a/shared/utils/domains.test.ts b/shared/utils/domains.test.ts index 1f7ada79d..ebec54c91 100644 --- a/shared/utils/domains.test.ts +++ b/shared/utils/domains.test.ts @@ -1,149 +1,180 @@ -import { stripSubdomain, parseDomain, isCustomSubdomain } from "./domains"; +import env from "@shared/env"; +import { parseDomain, getCookieDomain } from "./domains"; + // test suite is based on subset of parse-domain module we want to support // https://github.com/peerigon/parse-domain/blob/master/test/parseDomain.test.js describe("#parseDomain", () => { + beforeEach(() => { + env.URL = "https://example.com"; + }); + it("should remove the protocol", () => { expect(parseDomain("http://example.com")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); expect(parseDomain("//example.com")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); expect(parseDomain("https://example.com")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); }); - it("should remove sub-domains", () => { + it("should find team sub-domains", () => { + expect(parseDomain("myteam.example.com")).toMatchObject({ + teamSubdomain: "myteam", + host: "myteam.example.com", + custom: false, + }); + }); + + it("should ignore reserved sub-domains", () => { expect(parseDomain("www.example.com")).toMatchObject({ - subdomain: "www", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "www.example.com", + custom: false, }); }); it("should remove the path", () => { expect(parseDomain("example.com/some/path?and&query")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); expect(parseDomain("example.com/")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); }); it("should remove the query string", () => { - expect(parseDomain("example.com?and&query")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + expect(parseDomain("www.example.com?and&query")).toMatchObject({ + teamSubdomain: "", + host: "www.example.com", + custom: false, }); }); it("should remove special characters", () => { - expect(parseDomain("http://m.example.com\r")).toMatchObject({ - subdomain: "m", - domain: "example", - tld: "com", + expect(parseDomain("http://example.com\r")).toMatchObject({ + teamSubdomain: "", + host: "example.com", + custom: false, }); }); it("should remove the port", () => { expect(parseDomain("example.com:8080")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); }); it("should allow @ characters in the path", () => { expect(parseDomain("https://medium.com/@username/")).toMatchObject({ - subdomain: "", - domain: "medium", - tld: "com", + teamSubdomain: "", + host: "medium.com", + custom: true, }); }); - it("should also work with three-level domains like .co.uk", () => { - expect(parseDomain("www.example.co.uk")).toMatchObject({ - subdomain: "www", - domain: "example", - tld: "co.uk", - }); - }); - - it("should not include private domains like blogspot.com by default", () => { + it("should recognize include private domains like blogspot.com as custom", () => { expect(parseDomain("foo.blogspot.com")).toMatchObject({ - subdomain: "foo", - domain: "blogspot", - tld: "com", + teamSubdomain: "", + host: "foo.blogspot.com", + custom: true, }); }); it("should also work with the minimum", () => { expect(parseDomain("example.com")).toMatchObject({ - subdomain: "", - domain: "example", - tld: "com", + teamSubdomain: "", + host: "example.com", + custom: false, }); }); - it("should return null if the given value is not a string", () => { - expect(parseDomain(undefined)).toBe(null); - expect(parseDomain("")).toBe(null); + it("should throw a TypeError if the given value is not a valid string", () => { + expect(() => parseDomain("")).toThrow(TypeError); + }); + + it("should also work with three-level domains like .co.uk", () => { + env.URL = "https://example.co.uk"; + expect(parseDomain("myteam.example.co.uk")).toMatchObject({ + teamSubdomain: "myteam", + host: "myteam.example.co.uk", + custom: false, + }); }); it("should work with custom top-level domains (eg .local)", () => { - expect(parseDomain("mymachine.local")).toMatchObject({ - subdomain: "", - domain: "mymachine", - tld: "local", + env.URL = "mymachine.local"; + expect(parseDomain("myteam.mymachine.local")).toMatchObject({ + teamSubdomain: "myteam", + host: "myteam.mymachine.local", + custom: false, + }); + }); + + it("should work with localhost", () => { + env.URL = "http://localhost:3000"; + expect(parseDomain("https://localhost:3000/foo/bar?q=12345")).toMatchObject( + { + teamSubdomain: "", + host: "localhost", + custom: false, + } + ); + }); + + it("should work with localhost subdomains", () => { + env.URL = "http://localhost:3000"; + expect(parseDomain("https://www.localhost:3000")).toMatchObject({ + teamSubdomain: "", + host: "www.localhost", + custom: false, + }); + expect(parseDomain("https://myteam.localhost:3000")).toMatchObject({ + teamSubdomain: "myteam", + host: "myteam.localhost", + custom: false, }); }); }); -describe("#stripSubdomain", () => { - test("to work with localhost", () => { - expect(stripSubdomain("localhost")).toBe("localhost"); + +describe("#getCookieDomain", () => { + beforeEach(() => { + env.URL = "https://example.com"; + env.SUBDOMAINS_ENABLED = true; }); - test("to return domains without a subdomain", () => { - expect(stripSubdomain("example")).toBe("example"); - expect(stripSubdomain("example.com")).toBe("example.com"); - expect(stripSubdomain("example.org:3000")).toBe("example.org"); + it("returns the normalized app host when on the host domain", () => { + expect(getCookieDomain("subdomain.example.com")).toBe("example.com"); + expect(getCookieDomain("www.example.com")).toBe("example.com"); + expect(getCookieDomain("http://example.com:3000")).toBe("example.com"); + expect(getCookieDomain("myteam.example.com/document/12345?q=query")).toBe( + "example.com" + ); }); - test("to remove subdomains", () => { - expect(stripSubdomain("test.example.com")).toBe("example.com"); - expect(stripSubdomain("test.example.com:3000")).toBe("example.com"); - }); -}); -describe("#isCustomSubdomain", () => { - test("to work with localhost", () => { - expect(isCustomSubdomain("localhost")).toBe(false); - }); - - test("to return false for domains without a subdomain", () => { - expect(isCustomSubdomain("example")).toBe(false); - expect(isCustomSubdomain("example.com")).toBe(false); - expect(isCustomSubdomain("example.org:3000")).toBe(false); - }); - - test("to return false for www", () => { - expect(isCustomSubdomain("www.example.com")).toBe(false); - expect(isCustomSubdomain("www.example.com:3000")).toBe(false); - }); - - test("to return true for subdomains", () => { - expect(isCustomSubdomain("test.example.com")).toBe(true); - expect(isCustomSubdomain("test.example.com:3000")).toBe(true); + it("returns the input if not on the host domain", () => { + expect(getCookieDomain("www.blogspot.com")).toBe("www.blogspot.com"); + expect(getCookieDomain("anything else")).toBe("anything else"); + }); + + it("always returns the input when subdomains are not enabled", () => { + env.SUBDOMAINS_ENABLED = false; + expect(getCookieDomain("example.com")).toBe("example.com"); + expect(getCookieDomain("www.blogspot.com")).toBe("www.blogspot.com"); + expect(getCookieDomain("anything else")).toBe("anything else"); }); }); diff --git a/shared/utils/domains.ts b/shared/utils/domains.ts index 9daafff62..a27b8633a 100644 --- a/shared/utils/domains.ts +++ b/shared/utils/domains.ts @@ -1,85 +1,72 @@ import { trim } from "lodash"; +import env from "../env"; type Domain = { - tld: string; - subdomain: string; - domain: string; + teamSubdomain: string; + host: string; + custom: boolean; }; +// strips protocol and whitespace from input +// then strips the path and query string +function normalizeUrl(url: string) { + return trim(url.replace(/(https?:)?\/\//, "")).split(/[/:?]/)[0]; +} + +// The base domain is where root cookies are set in hosted mode +// It's also appended to a team's hosted subdomain to form their app URL +export function getBaseDomain() { + const normalEnvUrl = normalizeUrl(env.URL); + const tokens = normalEnvUrl.split("."); + + // remove reserved subdomains like "app" + // from the env URL to form the base domain + return tokens.length > 1 && RESERVED_SUBDOMAINS.includes(tokens[0]) + ? tokens.slice(1).join(".") + : normalEnvUrl; +} + // we originally used the parse-domain npm module however this includes // a large list of possible TLD's which increase the size of the bundle // unnecessarily for our usecase of trusted input. -export function parseDomain(url?: string): Domain | null | undefined { - if (typeof url !== "string") { - return null; - } - if (url === "") { - return null; +export function parseDomain(url: string): Domain { + if (!url) { + throw new TypeError("a non-empty url is required"); } - // strip extermeties and whitespace from input - const normalizedDomain = trim(url.replace(/(https?:)?\/\//, "")); - const parts = normalizedDomain.split("."); + const host = normalizeUrl(url); + const baseDomain = getBaseDomain(); - // ensure the last part only includes something that looks like a TLD - function cleanTLD(tld = "") { - return tld.split(/[/:?]/)[0]; + // if the url doesn't include the base url, then it must be a custom domain + const baseUrlStart = host === baseDomain ? 0 : host.indexOf(`.${baseDomain}`); + + if (baseUrlStart === -1) { + return { teamSubdomain: "", host, custom: true }; } - // simplistic subdomain parse, we don't need to take into account subdomains - // with "." characters as these are not valid in Outline - if (parts.length >= 3) { - return { - subdomain: parts[0], - domain: parts[1], - tld: cleanTLD(parts.slice(2).join(".")), - }; - } + // we consider anything in front of the baseUrl to be the subdomain + const subdomain = host.substring(0, baseUrlStart); + const isReservedSubdomain = RESERVED_SUBDOMAINS.includes(subdomain); - if (parts.length === 2) { - return { - subdomain: "", - domain: parts[0], - tld: cleanTLD(parts.slice(1).join(".")), - }; - } - - // one-part domain handler for things like localhost - if (parts.length === 1) { - return { - subdomain: "", - domain: cleanTLD(parts.slice(0).join()), - tld: "", - }; - } - - return null; + return { + teamSubdomain: isReservedSubdomain ? "" : subdomain, + host, + custom: false, + }; } -export function stripSubdomain(hostname: string) { - const parsed = parseDomain(hostname); - if (!parsed) { - return hostname; - } - if (parsed.tld) { - return `${parsed.domain}.${parsed.tld}`; - } - return parsed.domain; -} +export function getCookieDomain(domain: string) { + // always use the base URL for cookies when in hosted mode + // and the domain is not custom + if (env.SUBDOMAINS_ENABLED) { + const parsed = parseDomain(domain); -export function isCustomSubdomain(hostname: string) { - const parsed = parseDomain(hostname); - - if ( - !parsed || - !parsed.subdomain || - parsed.subdomain === "app" || - parsed.subdomain === "www" - ) { - return false; + if (!parsed.custom) { + return getBaseDomain(); + } } - return true; + return domain; } export const RESERVED_SUBDOMAINS = [ diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index a4d30c85b..8240d617f 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -1,32 +1,32 @@ +import env from "../env"; import { parseDomain } from "./domains"; -const env = typeof window !== "undefined" ? window.env : process.env; - export function cdnPath(path: string): string { return `${env.CDN_URL}${path}`; } +// TODO: HACK: if this is called server-side, it will always return false. +// - The only call sites to this function and isExternalUrl are on the client +// - The reason this is in a shared util is because it's used in an editor plugin +// which is also in the shared code export function isInternalUrl(href: string) { + // empty strings are never internal + if (href === "") { + return false; + } + + // relative paths are always internal if (href[0] === "/") { return true; } + const outline = typeof window !== "undefined" ? parseDomain(window.location.href) : undefined; - const parsed = parseDomain(href); - if ( - parsed && - outline && - parsed.subdomain === outline.subdomain && - parsed.domain === outline.domain && - parsed.tld === outline.tld - ) { - return true; - } - - return false; + const domain = parseDomain(href); + return outline?.host === domain.host; } export function isExternalUrl(href: string) {