diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index 89af24993..17dcd47c2 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -4,6 +4,7 @@ import DesktopRedirect from "~/scenes/DesktopRedirect";
import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
+import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
@@ -25,30 +26,37 @@ export default function Routes() {
}
>
-
-
-
-
-
+ {env.ROOT_SHARE_ID ? (
+
+
+
+
+ ) : (
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+ )}
);
}
diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx
index 756b9eab7..d8dfdec38 100644
--- a/app/scenes/Document/Shared.tsx
+++ b/app/scenes/Document/Shared.tsx
@@ -95,7 +95,7 @@ function SharedDocumentScene(props: Props) {
const [response, setResponse] = React.useState();
const [error, setError] = React.useState();
const { documents } = useStores();
- const { shareId, documentSlug } = props.match.params;
+ const { shareId = env.ROOT_SHARE_ID, documentSlug } = props.match.params;
const documentId = useDocumentId(documentSlug, response);
const themeOverride = ["dark", "light"].includes(
searchParams.get("theme") || ""
@@ -185,7 +185,7 @@ function SharedDocumentScene(props: Props) {
title={response.document.title}
sidebar={
response.sharedTree?.children.length ? (
-
+
) : undefined
}
>
diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts
index c32d6abc4..d71fc03c8 100644
--- a/app/utils/routeHelpers.ts
+++ b/app/utils/routeHelpers.ts
@@ -2,6 +2,7 @@ import queryString from "query-string";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
+import env from "~/env";
export function homePath(): string {
return "/home";
@@ -115,6 +116,10 @@ export function searchPath(
}
export function sharedDocumentPath(shareId: string, docPath?: string) {
+ if (shareId === env.ROOT_SHARE_ID) {
+ return docPath ? docPath : "/";
+ }
+
return docPath ? `/s/${shareId}${docPath}` : `/s/${shareId}`;
}
diff --git a/server/migrations/20231101021239-share-domain.js b/server/migrations/20231101021239-share-domain.js
new file mode 100644
index 000000000..4a8a12341
--- /dev/null
+++ b/server/migrations/20231101021239-share-domain.js
@@ -0,0 +1,15 @@
+'use strict';
+
+module.exports = {
+ async up (queryInterface, Sequelize) {
+ await queryInterface.addColumn("shares", "domain", {
+ type: Sequelize.STRING,
+ allowNull: true,
+ unique: true,
+ });
+ },
+
+ async down (queryInterface) {
+ await queryInterface.removeColumn("shares", "domain");
+ }
+};
diff --git a/server/models/Share.ts b/server/models/Share.ts
index 797b3e75c..b092e9129 100644
--- a/server/models/Share.ts
+++ b/server/models/Share.ts
@@ -1,3 +1,4 @@
+import { type SaveOptions } from "sequelize";
import {
ForeignKey,
BelongsTo,
@@ -9,14 +10,19 @@ import {
Default,
AllowNull,
Is,
+ Unique,
+ BeforeUpdate,
} from "sequelize-typescript";
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
+import { ValidationError } from "@server/errors";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
+import IsFQDN from "./validators/IsFQDN";
+import Length from "./validators/Length";
@DefaultScope(() => ({
include: [
@@ -88,6 +94,36 @@ class Share extends IdModel {
@Column
urlId: string | null | undefined;
+ @Unique
+ @Length({ max: 255, msg: "domain must be 255 characters or less" })
+ @IsFQDN
+ @Column
+ domain: string | null;
+
+ // hooks
+
+ @BeforeUpdate
+ static async checkDomain(model: Share, options: SaveOptions) {
+ if (!model.domain) {
+ return model;
+ }
+
+ model.domain = model.domain.toLowerCase();
+
+ const count = await Team.count({
+ ...options,
+ where: {
+ domain: model.domain,
+ },
+ });
+
+ if (count > 0) {
+ throw ValidationError("Domain is already in use");
+ }
+
+ return model;
+ }
+
// getters
get isRevoked() {
diff --git a/server/models/Team.ts b/server/models/Team.ts
index 8f91ccb4e..7fb9e81cc 100644
--- a/server/models/Team.ts
+++ b/server/models/Team.ts
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { URL } from "url";
import util from "util";
+import { type SaveOptions } from "sequelize";
import { Op } from "sequelize";
import {
Column,
@@ -19,6 +20,7 @@ import {
IsUUID,
AllowNull,
AfterUpdate,
+ BeforeUpdate,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import {
@@ -28,12 +30,14 @@ import {
} from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env";
+import { ValidationError } from "@server/errors";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Document from "./Document";
+import Share from "./Share";
import TeamDomain from "./TeamDomain";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
@@ -328,6 +332,28 @@ class Team extends ParanoidModel {
// hooks
+ @BeforeUpdate
+ static async checkDomain(model: Team, options: SaveOptions) {
+ if (!model.domain) {
+ return model;
+ }
+
+ model.domain = model.domain.toLowerCase();
+
+ const count = await Share.count({
+ ...options,
+ where: {
+ domain: model.domain,
+ },
+ });
+
+ if (count > 0) {
+ throw ValidationError("Domain is already in use");
+ }
+
+ return model;
+ }
+
@AfterUpdate
static deletePreviousAvatar = async (model: Team) => {
if (
diff --git a/server/presenters/env.ts b/server/presenters/env.ts
index ce90e34fe..30fc606a2 100644
--- a/server/presenters/env.ts
+++ b/server/presenters/env.ts
@@ -6,7 +6,10 @@ import { Integration } from "@server/models";
// do not add anything here that should be a secret or password
export default function present(
env: Environment,
- analytics?: Integration | null
+ options: {
+ analytics?: Integration | null;
+ rootShareId?: string | null;
+ } = {}
): PublicEnv {
return {
URL: env.URL.replace(/\/$/, ""),
@@ -29,9 +32,11 @@ export default function present(
RELEASE:
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
APP_NAME: env.APP_NAME,
+ ROOT_SHARE_ID: options.rootShareId || undefined,
+
analytics: {
- service: analytics?.service,
- settings: analytics?.settings,
+ service: options.analytics?.service,
+ settings: options.analytics?.settings,
},
};
}
diff --git a/server/routes/app.ts b/server/routes/app.ts
index 01a91429e..1a895a735 100644
--- a/server/routes/app.ts
+++ b/server/routes/app.ts
@@ -54,6 +54,7 @@ export const renderApp = async (
description?: string;
canonical?: string;
shortcutIcon?: string;
+ rootShareId?: string;
analytics?: Integration | null;
} = {}
) => {
@@ -72,7 +73,7 @@ export const renderApp = async (
const page = await readIndexFile();
const environment = `
`;
@@ -106,7 +107,10 @@ export const renderApp = async (
};
export const renderShare = async (ctx: Context, next: Next) => {
- const { shareId, documentSlug } = ctx.params;
+ const rootShareId = ctx.state.rootShare?.id;
+ const shareId = rootShareId ?? ctx.params.shareId;
+ const documentSlug = ctx.params.documentSlug;
+
// Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity
@@ -159,6 +163,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
? team.avatarUrl
: undefined,
analytics,
+ rootShareId,
canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined,
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 8c3b072ff..5a4d87df3 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -6,11 +6,13 @@ import compress from "koa-compress";
import Router from "koa-router";
import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
+import { Op } from "sequelize";
import { languages } from "@shared/i18n";
import { IntegrationType } from "@shared/types";
+import { parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
-import { Integration } from "@server/models";
+import { Integration, Share } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots";
@@ -137,6 +139,25 @@ router.get("*", async (ctx, next) => {
return;
}
+ const isCustomDomain = parseDomain(ctx.host).custom;
+ const isDevelopment = env.ENVIRONMENT === "development";
+ if (!team && (isDevelopment || (isCustomDomain && env.isCloudHosted))) {
+ const share = await Share.unscoped().findOne({
+ where: {
+ domain: ctx.hostname,
+ published: true,
+ revokedAt: {
+ [Op.is]: null,
+ },
+ },
+ });
+
+ if (share) {
+ ctx.state.rootShare = share;
+ return renderShare(ctx, next);
+ }
+ }
+
const analytics = team
? await Integration.findOne({
where: {
diff --git a/server/utils/passport.ts b/server/utils/passport.ts
index 6d8aef191..bf9836c06 100644
--- a/server/utils/passport.ts
+++ b/server/utils/passport.ts
@@ -116,6 +116,8 @@ export async function getTeamFromContext(ctx: Context) {
} else {
team = await Team.findOne();
}
+ } else if (ctx.state.rootShare) {
+ team = await Team.findByPk(ctx.state.rootShare.teamId);
} else if (domain.custom) {
team = await Team.findOne({ where: { domain: domain.host } });
} else if (domain.teamSubdomain) {
diff --git a/shared/types.ts b/shared/types.ts
index 5f72a9635..b5234cbeb 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -60,6 +60,7 @@ export type PublicEnv = {
GOOGLE_ANALYTICS_ID: string | undefined;
RELEASE: string | undefined;
APP_NAME: string;
+ ROOT_SHARE_ID?: string;
analytics: {
service?: IntegrationService;
settings?: IntegrationSettings;