From 2838503273d70b8585d1c4291d89f4d3c68c5473 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 1 Nov 2023 22:10:00 -0400 Subject: [PATCH] Backend of public sharing at root (#6103) --- app/routes/index.tsx | 50 +++++++++++-------- app/scenes/Document/Shared.tsx | 4 +- app/utils/routeHelpers.ts | 5 ++ .../migrations/20231101021239-share-domain.js | 15 ++++++ server/models/Share.ts | 36 +++++++++++++ server/models/Team.ts | 26 ++++++++++ server/presenters/env.ts | 11 ++-- server/routes/app.ts | 9 +++- server/routes/index.ts | 23 ++++++++- server/utils/passport.ts | 2 + shared/types.ts | 1 + 11 files changed, 153 insertions(+), 29 deletions(-) create mode 100644 server/migrations/20231101021239-share-domain.js 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;