Ability to create share url slug (#4550)
* feat: share url slug * feat: add col urlId * feat: allow updating urlId * fix: typo * fix: migrations * fix: urlId model validation * fix: input label * fix: debounce slug request * feat: link preview * fix: send slug variant in response if available * fix: temporary redirect to slug variant if available * fix: move up the custom link field * fix: process and display backend err * fix: reset custom link state on popover close and remove isCopied * fix: document link preview * fix: set urlId when available * fix: keep unique(urlId, teamId) * fix: codeql * fix: get rid of preview type * fix: width not needed for block elem * fix: migrations * fix: array not required * fix: use val * fix: validation on shareId and test * fix: allow clearing urlId * fix: do not escape * fix: unique error text * fix: keep team
This commit is contained in:
@@ -5,6 +5,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Text from "~/components/Text";
|
||||||
import { undraggableOnDesktop } from "~/styles";
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
|
|
||||||
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
||||||
@@ -120,6 +121,7 @@ export type Props = React.InputHTMLAttributes<
|
|||||||
flex?: boolean;
|
flex?: boolean;
|
||||||
short?: boolean;
|
short?: boolean;
|
||||||
margin?: string | number;
|
margin?: string | number;
|
||||||
|
error?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
innerRef?: React.Ref<any>;
|
innerRef?: React.Ref<any>;
|
||||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||||
@@ -155,6 +157,7 @@ class Input extends React.Component<Props> {
|
|||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
margin,
|
margin,
|
||||||
|
error,
|
||||||
className,
|
className,
|
||||||
short,
|
short,
|
||||||
flex,
|
flex,
|
||||||
@@ -197,11 +200,26 @@ class Input extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
</Outline>
|
</Outline>
|
||||||
</label>
|
</label>
|
||||||
|
<TextWrapper>
|
||||||
|
<StyledText type="danger" size="xsmall">
|
||||||
|
{error}
|
||||||
|
</StyledText>
|
||||||
|
</TextWrapper>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TextWrapper = styled.span`
|
||||||
|
min-height: 16px;
|
||||||
|
display: block;
|
||||||
|
margin-top: -16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const StyledText = styled(Text)`
|
||||||
|
margin-bottom: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
export const ReactHookWrappedInput = React.forwardRef(
|
export const ReactHookWrappedInput = React.forwardRef(
|
||||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||||
return <Input {...{ ...props, innerRef: ref }} />;
|
return <Input {...{ ...props, innerRef: ref }} />;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "secondary" | "tertiary";
|
type?: "secondary" | "tertiary" | "danger";
|
||||||
size?: "large" | "small" | "xsmall";
|
size?: "large" | "small" | "xsmall";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ const Text = styled.p<Props>`
|
|||||||
? props.theme.textSecondary
|
? props.theme.textSecondary
|
||||||
: props.type === "tertiary"
|
: props.type === "tertiary"
|
||||||
? props.theme.textTertiary
|
? props.theme.textTertiary
|
||||||
|
: props.type === "danger"
|
||||||
|
? props.theme.brand.red
|
||||||
: props.theme.text};
|
: props.theme.text};
|
||||||
font-size: ${(props) =>
|
font-size: ${(props) =>
|
||||||
props.size === "large"
|
props.size === "large"
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class Share extends BaseModel {
|
|||||||
@observable
|
@observable
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
urlId: string;
|
||||||
|
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
|
|
||||||
documentUrl: string;
|
documentUrl: string;
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
|
import { debounce, isEmpty } from "lodash";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons";
|
import { ExpandedIcon, GlobeIcon, PadlockIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Share from "~/models/Share";
|
import Share from "~/models/Share";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Input, {
|
||||||
|
TextWrapper,
|
||||||
|
StyledText as DocumentLinkPreview,
|
||||||
|
} from "~/components/Input";
|
||||||
import Notice from "~/components/Notice";
|
import Notice from "~/components/Notice";
|
||||||
import Switch from "~/components/Switch";
|
import Switch from "~/components/Switch";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
@@ -40,9 +46,10 @@ function SharePopover({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { shares } = useStores();
|
const { shares } = useStores();
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const [isCopied, setIsCopied] = React.useState(false);
|
|
||||||
const [expandedOptions, setExpandedOptions] = React.useState(false);
|
const [expandedOptions, setExpandedOptions] = React.useState(false);
|
||||||
const [isEditMode, setIsEditMode] = React.useState(false);
|
const [isEditMode, setIsEditMode] = React.useState(false);
|
||||||
|
const [slugValidationError, setSlugValidationError] = React.useState("");
|
||||||
|
const [urlSlug, setUrlSlug] = React.useState("");
|
||||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
const can = usePolicy(share ? share.id : "");
|
const can = usePolicy(share ? share.id : "");
|
||||||
@@ -73,6 +80,13 @@ function SharePopover({
|
|||||||
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
|
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
|
||||||
}, [document, visible, team.sharing]);
|
}, [document, visible, team.sharing]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setUrlSlug(share?.urlId || "");
|
||||||
|
setSlugValidationError("");
|
||||||
|
}
|
||||||
|
}, [share, visible]);
|
||||||
|
|
||||||
const handlePublishedChange = React.useCallback(
|
const handlePublishedChange = React.useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const share = shares.getByDocumentId(document.id);
|
const share = shares.getByDocumentId(document.id);
|
||||||
@@ -110,9 +124,7 @@ function SharePopover({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopied = React.useCallback(() => {
|
const handleCopied = React.useCallback(() => {
|
||||||
setIsCopied(true);
|
|
||||||
timeout.current = setTimeout(() => {
|
timeout.current = setTimeout(() => {
|
||||||
setIsCopied(false);
|
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
showToast(t("Share link copied"), {
|
showToast(t("Share link copied"), {
|
||||||
type: "info",
|
type: "info",
|
||||||
@@ -120,6 +132,38 @@ function SharePopover({
|
|||||||
}, 250);
|
}, 250);
|
||||||
}, [t, onRequestClose, showToast]);
|
}, [t, onRequestClose, showToast]);
|
||||||
|
|
||||||
|
const handleUrlSlugChange = React.useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (ev) => {
|
||||||
|
const share = shares.getByDocumentId(document.id);
|
||||||
|
invariant(share, "Share must exist");
|
||||||
|
|
||||||
|
const val = ev.target.value;
|
||||||
|
setUrlSlug(val);
|
||||||
|
if (val && !SHARE_URL_SLUG_REGEX.test(val)) {
|
||||||
|
setSlugValidationError(
|
||||||
|
t("Only lowercase letters, digits and dashes allowed")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSlugValidationError("");
|
||||||
|
if (share.urlId !== val) {
|
||||||
|
try {
|
||||||
|
await share.save({
|
||||||
|
urlId: isEmpty(val) ? null : val,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes("must be unique")) {
|
||||||
|
setSlugValidationError(
|
||||||
|
t("Sorry, this link has already been used")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[t, document.id, shares]
|
||||||
|
);
|
||||||
|
|
||||||
const userLocale = useUserLocale();
|
const userLocale = useUserLocale();
|
||||||
const locale = userLocale ? dateLocale(userLocale) : undefined;
|
const locale = userLocale ? dateLocale(userLocale) : undefined;
|
||||||
let shareUrl = team.sharing ? share?.url ?? "" : `${team.url}${document.url}`;
|
let shareUrl = team.sharing ? share?.url ?? "" : `${team.url}${document.url}`;
|
||||||
@@ -211,6 +255,31 @@ function SharePopover({
|
|||||||
|
|
||||||
{expandedOptions && (
|
{expandedOptions && (
|
||||||
<>
|
<>
|
||||||
|
<Separator />
|
||||||
|
<SwitchWrapper>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={t("Custom link")}
|
||||||
|
onChange={handleUrlSlugChange}
|
||||||
|
error={slugValidationError}
|
||||||
|
defaultValue={urlSlug}
|
||||||
|
/>
|
||||||
|
{!slugValidationError && urlSlug && (
|
||||||
|
<DocumentLinkPreviewWrapper>
|
||||||
|
<DocumentLinkPreview type="secondary" size="small">
|
||||||
|
<Trans>The document will be available at</Trans>
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={urlSlug ? `${team.url}/s/${urlSlug}` : ""}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{urlSlug ? `${team.url}/s/${urlSlug}` : ""}
|
||||||
|
</a>
|
||||||
|
</DocumentLinkPreview>
|
||||||
|
</DocumentLinkPreviewWrapper>
|
||||||
|
)}
|
||||||
|
</SwitchWrapper>
|
||||||
<Separator />
|
<Separator />
|
||||||
<SwitchWrapper>
|
<SwitchWrapper>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -252,7 +321,7 @@ function SharePopover({
|
|||||||
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
|
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isCopied || (!share && team.sharing)}
|
disabled={(!share && team.sharing) || slugValidationError}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
@@ -301,4 +370,8 @@ const SwitchText = styled(Text)`
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const DocumentLinkPreviewWrapper = styled(TextWrapper)`
|
||||||
|
margin-top: -12px;
|
||||||
|
`;
|
||||||
|
|
||||||
export default observer(SharePopover);
|
export default observer(SharePopover);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { Op } from "sequelize";
|
import { Op, WhereOptions } from "sequelize";
|
||||||
|
import isUUID from "validator/lib/isUUID";
|
||||||
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
InvalidRequestError,
|
InvalidRequestError,
|
||||||
@@ -12,6 +14,7 @@ import { authorize, can } from "@server/policies";
|
|||||||
type Props = {
|
type Props = {
|
||||||
id?: string;
|
id?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
|
teamId?: string;
|
||||||
user?: User;
|
user?: User;
|
||||||
includeState?: boolean;
|
includeState?: boolean;
|
||||||
};
|
};
|
||||||
@@ -25,6 +28,7 @@ type Result = {
|
|||||||
export default async function loadDocument({
|
export default async function loadDocument({
|
||||||
id,
|
id,
|
||||||
shareId,
|
shareId,
|
||||||
|
teamId,
|
||||||
user,
|
user,
|
||||||
includeState,
|
includeState,
|
||||||
}: Props): Promise<Result> {
|
}: Props): Promise<Result> {
|
||||||
@@ -36,14 +40,35 @@ export default async function loadDocument({
|
|||||||
throw AuthenticationError(`Authentication or shareId required`);
|
throw AuthenticationError(`Authentication or shareId required`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareUrlId =
|
||||||
|
shareId && !isUUID(shareId) && SHARE_URL_SLUG_REGEX.test(shareId)
|
||||||
|
? shareId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (shareUrlId && !teamId) {
|
||||||
|
throw InvalidRequestError(
|
||||||
|
"teamId required for fetching share using shareUrlId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (shareId) {
|
if (shareId) {
|
||||||
share = await Share.findOne({
|
let whereClause: WhereOptions<Share> = {
|
||||||
where: {
|
revokedAt: {
|
||||||
|
[Op.is]: null,
|
||||||
|
},
|
||||||
|
id: shareId,
|
||||||
|
};
|
||||||
|
if (shareUrlId) {
|
||||||
|
whereClause = {
|
||||||
revokedAt: {
|
revokedAt: {
|
||||||
[Op.is]: null,
|
[Op.is]: null,
|
||||||
},
|
},
|
||||||
id: shareId,
|
teamId,
|
||||||
},
|
urlId: shareUrlId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
share = await Share.findOne({
|
||||||
|
where: whereClause,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
// unscoping here allows us to return unpublished documents
|
// unscoping here allows us to return unpublished documents
|
||||||
|
|||||||
43
server/migrations/20221206163421-add-share-url-slug.js
Normal file
43
server/migrations/20221206163421-add-share-url-slug.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
try {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
"shares",
|
||||||
|
"urlId",
|
||||||
|
{
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.addConstraint("shares", {
|
||||||
|
fields: ["urlId", "teamId"],
|
||||||
|
type: "unique",
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch(err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
try {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.removeConstraint(
|
||||||
|
"shares",
|
||||||
|
"shares_urlId_teamId_uk",
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.removeColumn("shares", "urlId", { transaction });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
Scopes,
|
Scopes,
|
||||||
DataType,
|
DataType,
|
||||||
Default,
|
Default,
|
||||||
|
AllowNull,
|
||||||
|
Is,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import Team from "./Team";
|
import Team from "./Team";
|
||||||
@@ -85,6 +88,14 @@ class Share extends IdModel {
|
|||||||
@Column
|
@Column
|
||||||
views: number;
|
views: number;
|
||||||
|
|
||||||
|
@AllowNull
|
||||||
|
@Is({
|
||||||
|
args: SHARE_URL_SLUG_REGEX,
|
||||||
|
msg: "Must be only alphanumeric and dashes",
|
||||||
|
})
|
||||||
|
@Column
|
||||||
|
urlId: string | null | undefined;
|
||||||
|
|
||||||
// getters
|
// getters
|
||||||
|
|
||||||
get isRevoked() {
|
get isRevoked() {
|
||||||
@@ -92,7 +103,9 @@ class Share extends IdModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get canonicalUrl() {
|
get canonicalUrl() {
|
||||||
return `${this.team.url}/s/${this.id}`;
|
return this.urlId
|
||||||
|
? `${this.team.url}/s/${this.urlId}`
|
||||||
|
: `${this.team.url}/s/${this.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// associations
|
// associations
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default function present(share: Share, isAdmin = false) {
|
|||||||
documentUrl: share.document?.url,
|
documentUrl: share.document?.url,
|
||||||
published: share.published,
|
published: share.published,
|
||||||
url: share.canonicalUrl,
|
url: share.canonicalUrl,
|
||||||
|
urlId: share.urlId,
|
||||||
createdBy: presentUser(share.user),
|
createdBy: presentUser(share.user),
|
||||||
includeChildDocuments: share.includeChildDocuments,
|
includeChildDocuments: share.includeChildDocuments,
|
||||||
lastAccessedAt: share.lastAccessedAt || undefined,
|
lastAccessedAt: share.lastAccessedAt || undefined,
|
||||||
|
|||||||
@@ -466,10 +466,12 @@ describe("#documents.info", () => {
|
|||||||
it("should require a valid shareId", async () => {
|
it("should require a valid shareId", async () => {
|
||||||
const res = await server.post("/api/documents.info", {
|
const res = await server.post("/api/documents.info", {
|
||||||
body: {
|
body: {
|
||||||
shareId: 123,
|
shareId: "share_id",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("shareId: Invalid input");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
presentPolicies,
|
presentPolicies,
|
||||||
} from "@server/presenters";
|
} from "@server/presenters";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
|
import { getTeamFromContext } from "@server/utils/passport";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
import { assertPresent } from "@server/validation";
|
import { assertPresent } from "@server/validation";
|
||||||
import env from "../../../env";
|
import env from "../../../env";
|
||||||
@@ -394,10 +395,12 @@ router.post(
|
|||||||
async (ctx: APIContext<T.DocumentsInfoReq>) => {
|
async (ctx: APIContext<T.DocumentsInfoReq>) => {
|
||||||
const { id, shareId, apiVersion } = ctx.input;
|
const { id, shareId, apiVersion } = ctx.input;
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
|
const teamFromCtx = await getTeamFromContext(ctx);
|
||||||
const { document, share, collection } = await documentLoader({
|
const { document, share, collection } = await documentLoader({
|
||||||
id,
|
id,
|
||||||
shareId,
|
shareId,
|
||||||
user,
|
user,
|
||||||
|
teamId: teamFromCtx?.id,
|
||||||
});
|
});
|
||||||
const isPublic = cannot(user, "read", document);
|
const isPublic = cannot(user, "read", document);
|
||||||
const serializedDocument = await presentDocument(document, {
|
const serializedDocument = await presentDocument(document, {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
|
||||||
|
|
||||||
const DocumentsSortParamsSchema = z.object({
|
const DocumentsSortParamsSchema = z.object({
|
||||||
/** Specifies the attributes by which documents will be sorted in the list */
|
/** Specifies the attributes by which documents will be sorted in the list */
|
||||||
@@ -98,7 +100,10 @@ export const DocumentsInfoSchema = z
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
|
|
||||||
/** Share Id, if available */
|
/** Share Id, if available */
|
||||||
shareId: z.string().uuid().optional(),
|
shareId: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isUUID(val) || SHARE_URL_SLUG_REGEX.test(val))
|
||||||
|
.optional(),
|
||||||
|
|
||||||
/** Version of the API to be used */
|
/** Version of the API to be used */
|
||||||
apiVersion: z.number().optional(),
|
apiVersion: z.number().optional(),
|
||||||
|
|||||||
@@ -464,6 +464,70 @@ it("should require authorization", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("#shares.update", () => {
|
describe("#shares.update", () => {
|
||||||
|
it("should fail for invalid urlId", async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/shares.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: share.id,
|
||||||
|
urlId: "url_id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual(
|
||||||
|
"Must be only alphanumeric and dashes (urlId)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update urlId", async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/shares.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: share.id,
|
||||||
|
urlId: "url-id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.urlId).toEqual("url-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow clearing urlId", async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
await server.post("/api/shares.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: share.id,
|
||||||
|
urlId: "url-id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/shares.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: share.id,
|
||||||
|
urlId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.urlId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("should allow user to update a share", async () => {
|
it("should allow user to update a share", async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { isUndefined } from "lodash";
|
||||||
import { Op, WhereOptions } from "sequelize";
|
import { Op, WhereOptions } from "sequelize";
|
||||||
import { NotFoundError } from "@server/errors";
|
import { NotFoundError } from "@server/errors";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
@@ -162,7 +163,7 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("shares.update", auth(), async (ctx) => {
|
router.post("shares.update", auth(), async (ctx) => {
|
||||||
const { id, includeChildDocuments, published } = ctx.request.body;
|
const { id, includeChildDocuments, published, urlId } = ctx.request.body;
|
||||||
assertUuid(id, "id is required");
|
assertUuid(id, "id is required");
|
||||||
|
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
@@ -191,6 +192,10 @@ router.post("shares.update", auth(), async (ctx) => {
|
|||||||
share.includeChildDocuments = includeChildDocuments;
|
share.includeChildDocuments = includeChildDocuments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isUndefined(urlId)) {
|
||||||
|
share.urlId = urlId;
|
||||||
|
}
|
||||||
|
|
||||||
await share.save();
|
await share.save();
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: "shares.update",
|
name: "shares.update",
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import util from "util";
|
|||||||
import { Context, Next } from "koa";
|
import { Context, Next } from "koa";
|
||||||
import { escape } from "lodash";
|
import { escape } from "lodash";
|
||||||
import { Sequelize } from "sequelize";
|
import { Sequelize } from "sequelize";
|
||||||
|
import isUUID from "validator/lib/isUUID";
|
||||||
import documentLoader from "@server/commands/documentLoader";
|
import documentLoader from "@server/commands/documentLoader";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import presentEnv from "@server/presenters/env";
|
import presentEnv from "@server/presenters/env";
|
||||||
|
import { getTeamFromContext } from "@server/utils/passport";
|
||||||
import prefetchTags from "@server/utils/prefetchTags";
|
import prefetchTags from "@server/utils/prefetchTags";
|
||||||
|
|
||||||
const isProduction = env.ENVIRONMENT === "production";
|
const isProduction = env.ENVIRONMENT === "production";
|
||||||
@@ -87,11 +89,19 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
|||||||
let share, document;
|
let share, document;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const team = await getTeamFromContext(ctx);
|
||||||
const result = await documentLoader({
|
const result = await documentLoader({
|
||||||
id: documentSlug,
|
id: documentSlug,
|
||||||
shareId,
|
shareId,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
share = result.share;
|
share = result.share;
|
||||||
|
if (isUUID(shareId) && share && share.urlId) {
|
||||||
|
// Redirect temporarily because the url slug
|
||||||
|
// can be modified by the user at any time
|
||||||
|
ctx.redirect(`/s/${share.urlId}`);
|
||||||
|
ctx.status = 307;
|
||||||
|
}
|
||||||
document = result.document;
|
document = result.document;
|
||||||
|
|
||||||
if (share && !ctx.userAgent.isBot) {
|
if (share && !ctx.userAgent.isBot) {
|
||||||
|
|||||||
@@ -475,6 +475,8 @@
|
|||||||
"Backlinks": "Backlinks",
|
"Backlinks": "Backlinks",
|
||||||
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
||||||
"Share": "Share",
|
"Share": "Share",
|
||||||
|
"Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed",
|
||||||
|
"Sorry, this link has already been used": "Sorry, this link has already been used",
|
||||||
"Share this document": "Share this document",
|
"Share this document": "Share this document",
|
||||||
"This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared",
|
"This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared",
|
||||||
"Publish to internet": "Publish to internet",
|
"Publish to internet": "Publish to internet",
|
||||||
@@ -484,6 +486,8 @@
|
|||||||
"Share nested documents": "Share nested documents",
|
"Share nested documents": "Share nested documents",
|
||||||
"Nested documents are publicly available": "Nested documents are publicly available",
|
"Nested documents are publicly available": "Nested documents are publicly available",
|
||||||
"Nested documents are not shared": "Nested documents are not shared",
|
"Nested documents are not shared": "Nested documents are not shared",
|
||||||
|
"Custom link": "Custom link",
|
||||||
|
"The document will be available at": "The document will be available at",
|
||||||
"Automatically redirect to the editor": "Automatically redirect to the editor",
|
"Automatically redirect to the editor": "Automatically redirect to the editor",
|
||||||
"Users with edit permission will be redirected to the main app": "Users with edit permission will be redirected to the main app",
|
"Users with edit permission will be redirected to the main app": "Users with edit permission will be redirected to the main app",
|
||||||
"All users see the same publicly shared view": "All users see the same publicly shared view",
|
"All users see the same publicly shared view": "All users see the same publicly shared view",
|
||||||
|
|||||||
@@ -53,3 +53,5 @@ export function signin(service = "slack"): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SLUG_URL_REGEX = /^(?:[0-9a-zA-Z-_~]*-)?([a-zA-Z0-9]{10,15})$/;
|
export const SLUG_URL_REGEX = /^(?:[0-9a-zA-Z-_~]*-)?([a-zA-Z0-9]{10,15})$/;
|
||||||
|
|
||||||
|
export const SHARE_URL_SLUG_REGEX = /^[0-9a-z-]+$/;
|
||||||
|
|||||||
Reference in New Issue
Block a user