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 DelayedMount from "~/components/DelayedMount";
|
||||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||||
import Route from "~/components/ProfiledRoute";
|
import Route from "~/components/ProfiledRoute";
|
||||||
|
import env from "~/env";
|
||||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||||
@@ -25,6 +26,12 @@ export default function Routes() {
|
|||||||
</DelayedMount>
|
</DelayedMount>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{env.ROOT_SHARE_ID ? (
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/" component={SharedDocument} />
|
||||||
|
<Route exact path={`/doc/${slug}`} component={SharedDocument} />
|
||||||
|
</Switch>
|
||||||
|
) : (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Login} />
|
<Route exact path="/" component={Login} />
|
||||||
<Route exact path="/create" component={Login} />
|
<Route exact path="/create" component={Login} />
|
||||||
@@ -49,6 +56,7 @@ export default function Routes() {
|
|||||||
<AuthenticatedRoutes />
|
<AuthenticatedRoutes />
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
)}
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function SharedDocumentScene(props: Props) {
|
|||||||
const [response, setResponse] = React.useState<Response>();
|
const [response, setResponse] = React.useState<Response>();
|
||||||
const [error, setError] = React.useState<Error | null | undefined>();
|
const [error, setError] = React.useState<Error | null | undefined>();
|
||||||
const { documents } = useStores();
|
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 documentId = useDocumentId(documentSlug, response);
|
||||||
const themeOverride = ["dark", "light"].includes(
|
const themeOverride = ["dark", "light"].includes(
|
||||||
searchParams.get("theme") || ""
|
searchParams.get("theme") || ""
|
||||||
@@ -185,7 +185,7 @@ function SharedDocumentScene(props: Props) {
|
|||||||
title={response.document.title}
|
title={response.document.title}
|
||||||
sidebar={
|
sidebar={
|
||||||
response.sharedTree?.children.length ? (
|
response.sharedTree?.children.length ? (
|
||||||
<Sidebar rootNode={response.sharedTree} shareId={shareId} />
|
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import queryString from "query-string";
|
|||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import env from "~/env";
|
||||||
|
|
||||||
export function homePath(): string {
|
export function homePath(): string {
|
||||||
return "/home";
|
return "/home";
|
||||||
@@ -115,6 +116,10 @@ export function searchPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sharedDocumentPath(shareId: string, docPath?: string) {
|
export function sharedDocumentPath(shareId: string, docPath?: string) {
|
||||||
|
if (shareId === env.ROOT_SHARE_ID) {
|
||||||
|
return docPath ? docPath : "/";
|
||||||
|
}
|
||||||
|
|
||||||
return docPath ? `/s/${shareId}${docPath}` : `/s/${shareId}`;
|
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 {
|
import {
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
@@ -9,14 +10,19 @@ import {
|
|||||||
Default,
|
Default,
|
||||||
AllowNull,
|
AllowNull,
|
||||||
Is,
|
Is,
|
||||||
|
Unique,
|
||||||
|
BeforeUpdate,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import Team from "./Team";
|
import Team from "./Team";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import IdModel from "./base/IdModel";
|
import IdModel from "./base/IdModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
import IsFQDN from "./validators/IsFQDN";
|
||||||
|
import Length from "./validators/Length";
|
||||||
|
|
||||||
@DefaultScope(() => ({
|
@DefaultScope(() => ({
|
||||||
include: [
|
include: [
|
||||||
@@ -88,6 +94,36 @@ class Share extends IdModel {
|
|||||||
@Column
|
@Column
|
||||||
urlId: string | null | undefined;
|
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
|
// getters
|
||||||
|
|
||||||
get isRevoked() {
|
get isRevoked() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { URL } from "url";
|
import { URL } from "url";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
|
import { type SaveOptions } from "sequelize";
|
||||||
import { Op } from "sequelize";
|
import { Op } from "sequelize";
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
AllowNull,
|
AllowNull,
|
||||||
AfterUpdate,
|
AfterUpdate,
|
||||||
|
BeforeUpdate,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||||
import {
|
import {
|
||||||
@@ -28,12 +30,14 @@ import {
|
|||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||||
import Attachment from "./Attachment";
|
import Attachment from "./Attachment";
|
||||||
import AuthenticationProvider from "./AuthenticationProvider";
|
import AuthenticationProvider from "./AuthenticationProvider";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
|
import Share from "./Share";
|
||||||
import TeamDomain from "./TeamDomain";
|
import TeamDomain from "./TeamDomain";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import ParanoidModel from "./base/ParanoidModel";
|
import ParanoidModel from "./base/ParanoidModel";
|
||||||
@@ -328,6 +332,28 @@ class Team extends ParanoidModel {
|
|||||||
|
|
||||||
// hooks
|
// 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
|
@AfterUpdate
|
||||||
static deletePreviousAvatar = async (model: Team) => {
|
static deletePreviousAvatar = async (model: Team) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { Integration } from "@server/models";
|
|||||||
// do not add anything here that should be a secret or password
|
// do not add anything here that should be a secret or password
|
||||||
export default function present(
|
export default function present(
|
||||||
env: Environment,
|
env: Environment,
|
||||||
analytics?: Integration<IntegrationType.Analytics> | null
|
options: {
|
||||||
|
analytics?: Integration<IntegrationType.Analytics> | null;
|
||||||
|
rootShareId?: string | null;
|
||||||
|
} = {}
|
||||||
): PublicEnv {
|
): PublicEnv {
|
||||||
return {
|
return {
|
||||||
URL: env.URL.replace(/\/$/, ""),
|
URL: env.URL.replace(/\/$/, ""),
|
||||||
@@ -29,9 +32,11 @@ export default function present(
|
|||||||
RELEASE:
|
RELEASE:
|
||||||
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
|
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
|
||||||
APP_NAME: env.APP_NAME,
|
APP_NAME: env.APP_NAME,
|
||||||
|
ROOT_SHARE_ID: options.rootShareId || undefined,
|
||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
service: analytics?.service,
|
service: options.analytics?.service,
|
||||||
settings: analytics?.settings,
|
settings: options.analytics?.settings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const renderApp = async (
|
|||||||
description?: string;
|
description?: string;
|
||||||
canonical?: string;
|
canonical?: string;
|
||||||
shortcutIcon?: string;
|
shortcutIcon?: string;
|
||||||
|
rootShareId?: string;
|
||||||
analytics?: Integration | null;
|
analytics?: Integration | null;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
@@ -72,7 +73,7 @@ export const renderApp = async (
|
|||||||
const page = await readIndexFile();
|
const page = await readIndexFile();
|
||||||
const environment = `
|
const environment = `
|
||||||
<script nonce="${ctx.state.cspNonce}">
|
<script nonce="${ctx.state.cspNonce}">
|
||||||
window.env = ${JSON.stringify(presentEnv(env, options.analytics))};
|
window.env = ${JSON.stringify(presentEnv(env, options))};
|
||||||
</script>
|
</script>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -106,7 +107,10 @@ export const renderApp = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const renderShare = async (ctx: Context, next: Next) => {
|
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
|
// 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
|
// can be be returned in the server-rendered HTML. This allows it to appear in
|
||||||
// unfurls with more reliablity
|
// unfurls with more reliablity
|
||||||
@@ -159,6 +163,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
|||||||
? team.avatarUrl
|
? team.avatarUrl
|
||||||
: undefined,
|
: undefined,
|
||||||
analytics,
|
analytics,
|
||||||
|
rootShareId,
|
||||||
canonical: share
|
canonical: share
|
||||||
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
|
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import compress from "koa-compress";
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import send from "koa-send";
|
import send from "koa-send";
|
||||||
import userAgent, { UserAgentContext } from "koa-useragent";
|
import userAgent, { UserAgentContext } from "koa-useragent";
|
||||||
|
import { Op } from "sequelize";
|
||||||
import { languages } from "@shared/i18n";
|
import { languages } from "@shared/i18n";
|
||||||
import { IntegrationType } from "@shared/types";
|
import { IntegrationType } from "@shared/types";
|
||||||
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { NotFoundError } from "@server/errors";
|
import { NotFoundError } from "@server/errors";
|
||||||
import { Integration } from "@server/models";
|
import { Integration, Share } from "@server/models";
|
||||||
import { opensearchResponse } from "@server/utils/opensearch";
|
import { opensearchResponse } from "@server/utils/opensearch";
|
||||||
import { getTeamFromContext } from "@server/utils/passport";
|
import { getTeamFromContext } from "@server/utils/passport";
|
||||||
import { robotsResponse } from "@server/utils/robots";
|
import { robotsResponse } from "@server/utils/robots";
|
||||||
@@ -137,6 +139,25 @@ router.get("*", async (ctx, next) => {
|
|||||||
return;
|
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
|
const analytics = team
|
||||||
? await Integration.findOne({
|
? await Integration.findOne({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export async function getTeamFromContext(ctx: Context) {
|
|||||||
} else {
|
} else {
|
||||||
team = await Team.findOne();
|
team = await Team.findOne();
|
||||||
}
|
}
|
||||||
|
} else if (ctx.state.rootShare) {
|
||||||
|
team = await Team.findByPk(ctx.state.rootShare.teamId);
|
||||||
} else if (domain.custom) {
|
} else if (domain.custom) {
|
||||||
team = await Team.findOne({ where: { domain: domain.host } });
|
team = await Team.findOne({ where: { domain: domain.host } });
|
||||||
} else if (domain.teamSubdomain) {
|
} else if (domain.teamSubdomain) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type PublicEnv = {
|
|||||||
GOOGLE_ANALYTICS_ID: string | undefined;
|
GOOGLE_ANALYTICS_ID: string | undefined;
|
||||||
RELEASE: string | undefined;
|
RELEASE: string | undefined;
|
||||||
APP_NAME: string;
|
APP_NAME: string;
|
||||||
|
ROOT_SHARE_ID?: string;
|
||||||
analytics: {
|
analytics: {
|
||||||
service?: IntegrationService;
|
service?: IntegrationService;
|
||||||
settings?: IntegrationSettings<IntegrationType.Analytics>;
|
settings?: IntegrationSettings<IntegrationType.Analytics>;
|
||||||
|
|||||||
Reference in New Issue
Block a user