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) {