Backend of public sharing at root (#6103)

This commit is contained in:
Tom Moor
2023-11-01 22:10:00 -04:00
committed by GitHub
parent 1d6ef2e1b3
commit 2838503273
11 changed files with 153 additions and 29 deletions

View File

@@ -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() {
</DelayedMount>
}
>
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
{env.ROOT_SHARE_ID ? (
<Switch>
<Route exact path="/" component={SharedDocument} />
<Route exact path={`/doc/${slug}`} component={SharedDocument} />
</Switch>
) : (
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
<Redirect
exact
from={`/share/:shareId/doc/${slug}`}
to={`/s/:shareId/doc/${slug}`}
/>
<Route
exact
path={`/s/:shareId/doc/${slug}`}
component={SharedDocument}
/>
<Redirect
exact
from={`/share/:shareId/doc/${slug}`}
to={`/s/:shareId/doc/${slug}`}
/>
<Route
exact
path={`/s/:shareId/doc/${slug}`}
component={SharedDocument}
/>
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>
</Switch>
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>
</Switch>
)}
</React.Suspense>
);
}

View File

@@ -95,7 +95,7 @@ function SharedDocumentScene(props: Props) {
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
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 ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
) : undefined
}
>

View File

@@ -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}`;
}

View File

@@ -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");
}
};

View File

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

View File

@@ -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 (

View File

@@ -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<IntegrationType.Analytics> | null
options: {
analytics?: Integration<IntegrationType.Analytics> | 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,
},
};
}

View File

@@ -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 = `
<script nonce="${ctx.state.cspNonce}">
window.env = ${JSON.stringify(presentEnv(env, options.analytics))};
window.env = ${JSON.stringify(presentEnv(env, options))};
</script>
`;
@@ -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,

View File

@@ -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: {

View File

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

View File

@@ -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<IntegrationType.Analytics>;