Backend of public sharing at root (#6103)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
15
server/migrations/20231101021239-share-domain.js
Normal file
15
server/migrations/20231101021239-share-domain.js
Normal 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");
|
||||
}
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user