mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Add email subscriptions to public docs (#11911)
* feat: Add email subscriptions to public docs
This commit is contained in:
@@ -60,6 +60,19 @@ function InnerPublicAccess(
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -194,6 +207,31 @@ function InnerPublicAccess(
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
|
||||
@@ -70,6 +70,19 @@ function PublicAccess(
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleSubscriptionsChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowSubscriptions: checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleShowLastModifiedChanged = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
@@ -238,6 +251,31 @@ function PublicAccess(
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Email subscriptions")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Allow viewers to subscribe and receive email notifications when this document is updated"
|
||||
)}
|
||||
>
|
||||
<NudeButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Email subscriptions")}
|
||||
checked={share?.allowSubscriptions ?? true}
|
||||
onChange={handleSubscriptionsChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { MoonIcon, SunIcon } from "outline-icons";
|
||||
import { MoonIcon, SunIcon, SubscribeIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { ShareSubscribeForm } from "./ShareSubscribeForm";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
|
||||
@@ -42,3 +49,28 @@ export const AppearanceAction = observer(() => {
|
||||
</Action>
|
||||
);
|
||||
});
|
||||
|
||||
export function SubscribeAction({ shareId }: { shareId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Action>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip content={t("Subscribe to updates")} placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
icon={<SubscribeIcon />}
|
||||
aria-label={t("Subscribe to updates")}
|
||||
neutral
|
||||
borderOnHover
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent side="bottom" align="end" width={340}>
|
||||
<ShareSubscribeForm shareId={shareId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Action>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FormEvent, ChangeEvent } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
/**
|
||||
* Subscribe form content displayed inside the popover.
|
||||
*/
|
||||
export function ShareSubscribeForm({ shareId }: { shareId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (ev: FormEvent) => {
|
||||
ev.preventDefault();
|
||||
setStatus("loading");
|
||||
try {
|
||||
await client.post("/shares.subscribe", { shareId, email });
|
||||
setStatus("success");
|
||||
} catch (err) {
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : t("Something went wrong.")
|
||||
);
|
||||
setStatus("error");
|
||||
}
|
||||
},
|
||||
[shareId, email]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(ev: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
if (status === "error") {
|
||||
setErrorMessage("");
|
||||
setStatus("idle");
|
||||
}
|
||||
},
|
||||
[status]
|
||||
);
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<FormContainer>
|
||||
<Text type="tertiary" size="small">
|
||||
{t("Check your email to confirm your subscription.")}
|
||||
</Text>
|
||||
</FormContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormContainer>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<Text as="label" type="tertiary" size="small">
|
||||
{t("Get notified when this document is updated")}
|
||||
</Text>
|
||||
<Flex align="center" gap={8}>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
placeholder={t("Email address")}
|
||||
required
|
||||
margin={0}
|
||||
flex
|
||||
/>
|
||||
<Button type="submit" disabled={status === "loading"} neutral>
|
||||
{t("Subscribe")}
|
||||
</Button>
|
||||
</Flex>
|
||||
{status === "error" && <ErrorText>{errorMessage}</ErrorText>}
|
||||
</StyledForm>
|
||||
</FormContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const FormContainer = styled.div`
|
||||
padding: 4px 0;
|
||||
`;
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p`
|
||||
font-size: 13px;
|
||||
color: ${s("danger")};
|
||||
margin: 0;
|
||||
`;
|
||||
@@ -70,6 +70,10 @@ class Share extends Model implements Searchable {
|
||||
@observable
|
||||
allowIndexing: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
allowSubscriptions: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
showLastUpdated: boolean;
|
||||
|
||||
@@ -38,7 +38,10 @@ import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import ObservingBanner from "./ObservingBanner";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
import ShareButton from "./ShareButton";
|
||||
import { AppearanceAction } from "~/components/Sharing/components/Actions";
|
||||
import {
|
||||
AppearanceAction,
|
||||
SubscribeAction,
|
||||
} from "~/components/Sharing/components/Actions";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { type Editor } from "~/editor";
|
||||
import { ChangesNavigation } from "./ChangesNavigation";
|
||||
@@ -92,7 +95,7 @@ function DocumentHeader({
|
||||
const { hasHeadings, editor } = useDocumentContext();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [measureRef, size] = useMeasure();
|
||||
const { isShare, shareId, sharedTree } = useShare();
|
||||
const { isShare, shareId, sharedTree, allowSubscriptions } = useShare();
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
@@ -210,6 +213,9 @@ function DocumentHeader({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{allowSubscriptions !== false && (
|
||||
<SubscribeAction shareId={shareId} />
|
||||
)}
|
||||
<AppearanceAction />
|
||||
{can.update && !isEditing ? editAction : <div />}
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { PublicTeam } from "@shared/types";
|
||||
import { TOCPosition } from "@shared/types";
|
||||
import type DocumentModel from "~/models/Document";
|
||||
import DocumentComponent from "~/scenes/Document/components/Document";
|
||||
import Branding from "~/components/Branding";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useTeamContext } from "~/components/TeamContext";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import Branding from "~/components/Branding";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
|
||||
type Props = {
|
||||
document: DocumentModel;
|
||||
|
||||
@@ -250,6 +250,7 @@ function SharedScene() {
|
||||
value={{
|
||||
shareId,
|
||||
sharedTree: share.tree,
|
||||
allowSubscriptions: share.allowSubscriptions,
|
||||
}}
|
||||
>
|
||||
<Helmet>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import { ShareSubscription } from "@server/models";
|
||||
import ShareSubscriptionHelper from "@server/models/helpers/ShareSubscriptionHelper";
|
||||
import type { EmailProps } from "./BaseEmail";
|
||||
import BaseEmail, { EmailMessageCategory } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type InputProps = EmailProps & {
|
||||
shareSubscriptionId: string;
|
||||
documentTitle: string;
|
||||
shareUrl: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
unsubscribeUrl: string;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a share subscriber when the shared document is updated.
|
||||
*/
|
||||
export default class ShareDocumentUpdatedEmail extends BaseEmail<
|
||||
InputProps,
|
||||
BeforeSend
|
||||
> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Notification;
|
||||
}
|
||||
|
||||
protected async beforeSend(props: InputProps) {
|
||||
const subscription = await ShareSubscription.findByPk(
|
||||
props.shareSubscriptionId
|
||||
);
|
||||
|
||||
if (
|
||||
!subscription ||
|
||||
subscription.isUnsubscribed ||
|
||||
!subscription.isConfirmed
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribeUrl: ShareSubscriptionHelper.unsubscribeUrl(subscription),
|
||||
};
|
||||
}
|
||||
|
||||
protected unsubscribeUrl({ unsubscribeUrl }: Props) {
|
||||
return unsubscribeUrl;
|
||||
}
|
||||
|
||||
protected subject({ documentTitle }: Props) {
|
||||
return this.t(`"{{ documentTitle }}" updated`, { documentTitle });
|
||||
}
|
||||
|
||||
protected preview({ documentTitle }: Props): string {
|
||||
return this.t('"{{ documentTitle }}" has been updated.', { documentTitle });
|
||||
}
|
||||
|
||||
protected renderAsText({ documentTitle, shareUrl }: Props): string {
|
||||
return `
|
||||
${this.t(`"{{ documentTitle }}" updated`, { documentTitle })}
|
||||
|
||||
${this.t("A document you subscribed to has been updated.")}
|
||||
|
||||
${this.t("View Document")}: ${shareUrl}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ documentTitle, shareUrl, unsubscribeUrl }: Props) {
|
||||
const documentLink = `${shareUrl}?ref=subscription-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate
|
||||
previewText={this.preview({ documentTitle } as Props)}
|
||||
goToAction={{ url: documentLink, name: this.t("View Document") }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
{this.t(`"{{ documentTitle }}" updated`, { documentTitle })}
|
||||
</Heading>
|
||||
<p>
|
||||
{this.t("A document you subscribed to has been updated.")}{" "}
|
||||
{this.t("Click below to view the latest version.")}
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={documentLink}>{this.t("View Document")}</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer
|
||||
unsubscribeUrl={unsubscribeUrl}
|
||||
unsubscribeText={this.t("Unsubscribe from these emails")}
|
||||
/>
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import env from "@server/env";
|
||||
import type { EmailProps } from "./BaseEmail";
|
||||
import BaseEmail, { EmailMessageCategory } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type Props = EmailProps & {
|
||||
documentTitle: string;
|
||||
confirmUrl: string;
|
||||
teamName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Email sent to confirm a share subscription request.
|
||||
*/
|
||||
export default class ShareSubscriptionConfirmEmail extends BaseEmail<Props> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Authentication;
|
||||
}
|
||||
|
||||
protected subject() {
|
||||
return this.t("Confirm your subscription");
|
||||
}
|
||||
|
||||
protected preview({ documentTitle }: Props) {
|
||||
return this.t(
|
||||
'Confirm your subscription to receive updates when "{{ documentTitle }}" changes.',
|
||||
{ documentTitle }
|
||||
);
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
documentTitle,
|
||||
confirmUrl,
|
||||
teamName,
|
||||
}: Props): string {
|
||||
const appName = teamName ?? env.APP_NAME;
|
||||
return `
|
||||
${this.t("Confirm your subscription")}
|
||||
|
||||
${this.t(
|
||||
'You requested to receive email notifications when "{{ documentTitle }}" is updated on {{ appName }}. Please confirm your subscription by following the link below.',
|
||||
{ documentTitle, appName }
|
||||
)}
|
||||
|
||||
${this.t("Confirm Subscription")}: ${confirmUrl}
|
||||
|
||||
${this.t("This link will expire in 24 hours.")}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ documentTitle, confirmUrl, teamName }: Props) {
|
||||
const appName = teamName ?? env.APP_NAME;
|
||||
return (
|
||||
<EmailTemplate previewText={this.preview({ documentTitle } as Props)}>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{this.t("Confirm your subscription")}</Heading>
|
||||
<p>
|
||||
{this.t(
|
||||
'You requested to receive email notifications when "{{ documentTitle }}" is updated on {{ appName }}.',
|
||||
{ documentTitle, appName }
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{this.t(
|
||||
"Please confirm your subscription by clicking the button below."
|
||||
)}
|
||||
</p>
|
||||
<EmptySpace height={5} />
|
||||
<p>
|
||||
<Button href={confirmUrl}>{this.t("Confirm Subscription")}</Button>
|
||||
</p>
|
||||
<EmptySpace height={5} />
|
||||
<p>
|
||||
<em>{this.t("This link will expire in 24 hours.")}</em>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.createTable(
|
||||
"share_subscriptions",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
shareId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "shares",
|
||||
key: "id",
|
||||
},
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
emailFingerprint: {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
secret: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
},
|
||||
ipAddress: {
|
||||
type: Sequelize.STRING(45),
|
||||
allowNull: true,
|
||||
},
|
||||
confirmedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
unsubscribedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
lastNotifiedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
"share_subscriptions",
|
||||
["shareId", "emailFingerprint"],
|
||||
{ unique: true, transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex(
|
||||
"share_subscriptions",
|
||||
["shareId", "confirmedAt"],
|
||||
{
|
||||
where: { unsubscribedAt: null },
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("share_subscriptions", ["ipAddress"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
return queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.dropTable("share_subscriptions", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("shares", "allowSubscriptions", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("shares", "allowSubscriptions");
|
||||
},
|
||||
};
|
||||
@@ -143,6 +143,10 @@ class Share extends IdModel<
|
||||
@Column
|
||||
allowIndexing: boolean;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
allowSubscriptions: boolean;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
showLastUpdated: boolean;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { randomString } from "@shared/random";
|
||||
import { buildShare } from "@server/test/factories";
|
||||
import ShareSubscription from "./ShareSubscription";
|
||||
|
||||
describe("ShareSubscription", () => {
|
||||
describe("generateConfirmToken / generateUnsubscribeToken", () => {
|
||||
it("should produce deterministic tokens", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "test@example.com",
|
||||
emailFingerprint: "test@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
const token1 = ShareSubscription.generateConfirmToken(subscription);
|
||||
const token2 = ShareSubscription.generateConfirmToken(subscription);
|
||||
expect(token1).toBe(token2);
|
||||
});
|
||||
|
||||
it("should produce different tokens for confirm vs unsubscribe", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "test@example.com",
|
||||
emailFingerprint: "test@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
const confirmToken = ShareSubscription.generateConfirmToken(subscription);
|
||||
const unsubscribeToken =
|
||||
ShareSubscription.generateUnsubscribeToken(subscription);
|
||||
expect(confirmToken).not.toBe(unsubscribeToken);
|
||||
});
|
||||
|
||||
it("should produce different tokens for different secrets", async () => {
|
||||
const share = await buildShare();
|
||||
const sub1 = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "test@example.com",
|
||||
emailFingerprint: "test@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
const sub2 = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "test2@example.com",
|
||||
emailFingerprint: "test2@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
expect(ShareSubscription.generateConfirmToken(sub1)).not.toBe(
|
||||
ShareSubscription.generateConfirmToken(sub2)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEmailFingerprint", () => {
|
||||
it("should return a hex hash string", () => {
|
||||
const fp =
|
||||
ShareSubscription.normalizeEmailFingerprint("user@example.com");
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should treat different cases as equivalent", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("User@Example.COM")
|
||||
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
|
||||
});
|
||||
|
||||
it("should remove dots from Gmail local part", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("first.last@gmail.com")
|
||||
).toBe(
|
||||
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve dots for non-Gmail domains", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("first.last@example.com")
|
||||
).not.toBe(
|
||||
ShareSubscription.normalizeEmailFingerprint("firstlast@example.com")
|
||||
);
|
||||
});
|
||||
|
||||
it("should strip +alias from local part", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("user+tag@example.com")
|
||||
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
|
||||
});
|
||||
|
||||
it("should handle dots and +alias together for Gmail", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint(
|
||||
"first.last+newsletter@gmail.com"
|
||||
)
|
||||
).toBe(
|
||||
ShareSubscription.normalizeEmailFingerprint("firstlast@gmail.com")
|
||||
);
|
||||
});
|
||||
|
||||
it("should not remove dots from domain", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("user@sub.example.com")
|
||||
).not.toBe(
|
||||
ShareSubscription.normalizeEmailFingerprint("user@subexample.com")
|
||||
);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint(" user@example.com ")
|
||||
).toBe(ShareSubscription.normalizeEmailFingerprint("user@example.com"));
|
||||
});
|
||||
|
||||
it("should treat gmail.com and googlemail.com as equivalent", () => {
|
||||
const gmail = ShareSubscription.normalizeEmailFingerprint(
|
||||
"first.last+tag@gmail.com"
|
||||
);
|
||||
const googlemail = ShareSubscription.normalizeEmailFingerprint(
|
||||
"first.last+tag@googlemail.com"
|
||||
);
|
||||
expect(gmail).toBe(googlemail);
|
||||
});
|
||||
|
||||
it("should not alter other domains ending in googlemail.com", () => {
|
||||
expect(
|
||||
ShareSubscription.normalizeEmailFingerprint("user@notgooglemail.com")
|
||||
).not.toBe(ShareSubscription.normalizeEmailFingerprint("user@gmail.com"));
|
||||
});
|
||||
|
||||
it("should handle email without @ gracefully", () => {
|
||||
const fp = ShareSubscription.normalizeEmailFingerprint("invalid");
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should strip null bytes to prevent injection bypasses", () => {
|
||||
const normal =
|
||||
ShareSubscription.normalizeEmailFingerprint("user@example.com");
|
||||
const withNull =
|
||||
ShareSubscription.normalizeEmailFingerprint("user\0@example.com");
|
||||
expect(withNull).toBe(normal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkIPLimit", () => {
|
||||
beforeEach(async () => {
|
||||
await ShareSubscription.destroy({ where: {}, force: true });
|
||||
});
|
||||
|
||||
it("should allow up to 3 unique emails from the same IP", async () => {
|
||||
const share = await buildShare();
|
||||
const ip = "192.168.1.1";
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: `user${i}@example.com`,
|
||||
emailFingerprint: `user${i}@example.com`,
|
||||
secret: randomString(32),
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user3@example.com",
|
||||
emailFingerprint: "user3@example.com",
|
||||
secret: randomString(32),
|
||||
ipAddress: ip,
|
||||
})
|
||||
).rejects.toThrow("limit");
|
||||
});
|
||||
|
||||
it("should not count subscriptions from different IPs", async () => {
|
||||
const share = await buildShare();
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: `user${i}@example.com`,
|
||||
emailFingerprint: `user${i}@example.com`,
|
||||
secret: randomString(32),
|
||||
ipAddress: `10.0.0.${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user3@example.com",
|
||||
emailFingerprint: "user3@example.com",
|
||||
secret: randomString(32),
|
||||
ipAddress: "10.0.0.3",
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("should not count duplicate fingerprints from the same IP", async () => {
|
||||
const share1 = await buildShare();
|
||||
const share2 = await buildShare();
|
||||
const ip = "192.168.2.1";
|
||||
|
||||
// Same fingerprint across different shares — should count as 1
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const share = await buildShare();
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "same@example.com",
|
||||
emailFingerprint: "same@example.com",
|
||||
secret: randomString(32),
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
|
||||
// 2 more unique fingerprints — total distinct = 3
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await ShareSubscription.create({
|
||||
shareId: share1.id,
|
||||
email: `other${i}@example.com`,
|
||||
emailFingerprint: `other${i}@example.com`,
|
||||
secret: randomString(32),
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
|
||||
// 4th unique fingerprint should be blocked
|
||||
await expect(
|
||||
ShareSubscription.create({
|
||||
shareId: share2.id,
|
||||
email: "blocked@example.com",
|
||||
emailFingerprint: "blocked@example.com",
|
||||
secret: randomString(32),
|
||||
ipAddress: ip,
|
||||
})
|
||||
).rejects.toThrow("limit");
|
||||
});
|
||||
|
||||
it("should skip the check if ipAddress is null", async () => {
|
||||
const share = await buildShare();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: `user${i}@example.com`,
|
||||
emailFingerprint: `user${i}@example.com`,
|
||||
secret: randomString(32),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import { type SaveOptions, Op } from "sequelize";
|
||||
import {
|
||||
BeforeCreate,
|
||||
Column,
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Table,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { subHours } from "date-fns";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import Share from "./Share";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Scopes(() => ({
|
||||
active: {
|
||||
where: {
|
||||
confirmedAt: { [Op.not]: null },
|
||||
unsubscribedAt: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "share_subscriptions", modelName: "share_subscription" })
|
||||
@Fix
|
||||
class ShareSubscription extends IdModel<
|
||||
InferAttributes<ShareSubscription>,
|
||||
Partial<InferCreationAttributes<ShareSubscription>>
|
||||
> {
|
||||
@BelongsTo(() => Share, "shareId")
|
||||
share: Share;
|
||||
|
||||
@ForeignKey(() => Share)
|
||||
@Column(DataType.UUID)
|
||||
shareId: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
email: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
emailFingerprint: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
secret: string;
|
||||
|
||||
@Column(DataType.STRING(45))
|
||||
ipAddress: string | null;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
confirmedAt: Date | null;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
unsubscribedAt: Date | null;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
lastNotifiedAt: Date | null;
|
||||
|
||||
/** Maximum number of unique email subscriptions allowed per IP address. */
|
||||
static maxSubscriptionsPerIP = 3;
|
||||
|
||||
@BeforeCreate
|
||||
static async checkIPLimit(
|
||||
model: ShareSubscription,
|
||||
options: SaveOptions<ShareSubscription>
|
||||
) {
|
||||
if (!model.ipAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.findAll({
|
||||
attributes: ["emailFingerprint"],
|
||||
where: { ipAddress: model.ipAddress },
|
||||
group: ["emailFingerprint"],
|
||||
transaction: options.transaction,
|
||||
});
|
||||
const count = results.length;
|
||||
|
||||
if (count >= this.maxSubscriptionsPerIP) {
|
||||
throw ValidationError(`You have reached the limit of subscriptions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this subscription has been confirmed via email.
|
||||
*/
|
||||
get isConfirmed(): boolean {
|
||||
return !!this.confirmedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this subscription has been unsubscribed.
|
||||
*/
|
||||
get isUnsubscribed(): boolean {
|
||||
return !!this.unsubscribedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the confirmation token has expired (24-hour window from last update).
|
||||
*/
|
||||
get isTokenExpired(): boolean {
|
||||
return this.updatedAt < subHours(new Date(), 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an email address into a fingerprint for uniqueness comparison.
|
||||
* Lowercases, removes dots from local part, and strips +alias suffixes.
|
||||
*
|
||||
* @param email The email address to normalize.
|
||||
* @returns The normalized email fingerprint.
|
||||
*/
|
||||
static normalizeEmailFingerprint(email: string): string {
|
||||
// Strip null bytes to prevent injection bypasses
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const lower = email.replace(/\0/g, "").toLowerCase().trim();
|
||||
const [localPart, domain] = lower.split("@");
|
||||
if (!localPart || !domain) {
|
||||
return crypto.createHash("sha256").update(lower).digest("hex");
|
||||
}
|
||||
|
||||
const withoutPlus = localPart.split("+")[0];
|
||||
|
||||
// Normalize googlemail.com to gmail.com as they are the same service
|
||||
const normalizedDomain = domain === "googlemail.com" ? "gmail.com" : domain;
|
||||
|
||||
// Gmail ignores dots in the local part; other providers treat them as significant
|
||||
const normalizedLocal =
|
||||
normalizedDomain === "gmail.com"
|
||||
? withoutPlus.replace(/\./g, "")
|
||||
: withoutPlus;
|
||||
|
||||
const normalized = `${normalizedLocal}@${normalizedDomain}`;
|
||||
return crypto.createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an HMAC token for confirming this subscription.
|
||||
*
|
||||
* @param subscription The subscription to generate a token for.
|
||||
* @returns The confirmation token as a hex string.
|
||||
*/
|
||||
static generateConfirmToken(subscription: ShareSubscription): string {
|
||||
return crypto
|
||||
.createHmac("sha256", subscription.secret)
|
||||
.update(`${subscription.shareId}:${subscription.email}:confirm`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an HMAC token for unsubscribing from this subscription.
|
||||
*
|
||||
* @param subscription The subscription to generate a token for.
|
||||
* @returns The unsubscribe token as a hex string.
|
||||
*/
|
||||
static generateUnsubscribeToken(subscription: ShareSubscription): string {
|
||||
return crypto
|
||||
.createHmac("sha256", subscription.secret)
|
||||
.update(`${subscription.shareId}:${subscription.email}:unsubscribe`)
|
||||
.digest("hex");
|
||||
}
|
||||
}
|
||||
|
||||
export default ShareSubscription;
|
||||
@@ -0,0 +1,38 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@server/env";
|
||||
import ShareSubscription from "@server/models/ShareSubscription";
|
||||
|
||||
/**
|
||||
* Helper class for working with share subscriptions.
|
||||
*/
|
||||
export default class ShareSubscriptionHelper {
|
||||
/**
|
||||
* Get the confirmation URL for a share subscription.
|
||||
*
|
||||
* @param subscription The share subscription to confirm.
|
||||
* @returns The confirmation URL.
|
||||
*/
|
||||
public static confirmUrl(subscription: ShareSubscription): string {
|
||||
const token = ShareSubscription.generateConfirmToken(subscription);
|
||||
|
||||
return `${env.URL}/api/shares.confirmSubscription?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unsubscribe URL for a share subscription.
|
||||
*
|
||||
* @param subscription The share subscription to unsubscribe.
|
||||
* @returns The unsubscribe URL.
|
||||
*/
|
||||
public static unsubscribeUrl(subscription: ShareSubscription): string {
|
||||
const token = ShareSubscription.generateUnsubscribeToken(subscription);
|
||||
|
||||
return `${env.URL}/api/shares.unsubscribe?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token,
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export { default as SearchQuery } from "./SearchQuery";
|
||||
|
||||
export { default as Share } from "./Share";
|
||||
|
||||
export { default as ShareSubscription } from "./ShareSubscription";
|
||||
|
||||
export { default as Star } from "./Star";
|
||||
|
||||
export { default as Team } from "./Team";
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function presentShare(share: Share, isAdmin = false) {
|
||||
createdBy: presentUser(share.user),
|
||||
includeChildDocuments: share.includeChildDocuments,
|
||||
allowIndexing: share.allowIndexing,
|
||||
allowSubscriptions: share.allowSubscriptions,
|
||||
showLastUpdated: share.showLastUpdated,
|
||||
showTOC: share.showTOC,
|
||||
lastAccessedAt: share.lastAccessedAt || undefined,
|
||||
|
||||
@@ -19,6 +19,7 @@ import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotifica
|
||||
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
|
||||
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
|
||||
import RevisionCreatedNotificationsTask from "../tasks/RevisionCreatedNotificationsTask";
|
||||
import ShareSubscriptionNotificationsTask from "../tasks/ShareSubscriptionNotificationsTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class NotificationsProcessor extends BaseProcessor {
|
||||
@@ -86,6 +87,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
await new RevisionCreatedNotificationsTask().schedule(event);
|
||||
await new ShareSubscriptionNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async collectionCreated(event: CollectionEvent) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { subHours } from "date-fns";
|
||||
import { randomString } from "@shared/random";
|
||||
import ShareDocumentUpdatedEmail from "@server/emails/templates/ShareDocumentUpdatedEmail";
|
||||
import { ShareSubscription } from "@server/models";
|
||||
import { buildDocument, buildShare } from "@server/test/factories";
|
||||
import ShareSubscriptionNotificationsTask from "./ShareSubscriptionNotificationsTask";
|
||||
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ShareSubscriptionNotificationsTask", () => {
|
||||
it("should send email to confirmed subscriber", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not send email to unconfirmed subscriber", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not send email to unsubscribed subscriber", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
unsubscribedAt: new Date(),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throttle notifications to once per 6 hours", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
lastNotifiedAt: subHours(new Date(), 3),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send if last notified more than 6 hours ago", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
lastNotifiedAt: subHours(new Date(), 7),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not send for unpublished shares", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
published: false,
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update lastNotifiedAt after sending", async () => {
|
||||
jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
emailFingerprint: "subscriber@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(subscription.lastNotifiedAt).toBeNull();
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.lastNotifiedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should send to multiple subscribers", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "sub1@example.com",
|
||||
emailFingerprint: "sub1@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "sub2@example.com",
|
||||
emailFingerprint: "sub2@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not send if document has no shares", async () => {
|
||||
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
|
||||
const document = await buildDocument();
|
||||
|
||||
const task = new ShareSubscriptionNotificationsTask();
|
||||
await task.perform({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
modelId: "revision-id",
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { subHours } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import ShareDocumentUpdatedEmail from "@server/emails/templates/ShareDocumentUpdatedEmail";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Share, ShareSubscription } from "@server/models";
|
||||
import type { RevisionEvent } from "@server/types";
|
||||
import { BaseTask, TaskPriority } from "./base/BaseTask";
|
||||
|
||||
export default class ShareSubscriptionNotificationsTask extends BaseTask<RevisionEvent> {
|
||||
public async perform(event: RevisionEvent) {
|
||||
const document = await Document.findByPk(event.documentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all published, non-revoked shares for this document
|
||||
const shares = await Share.findAll({
|
||||
where: {
|
||||
published: true,
|
||||
revokedAt: null,
|
||||
[Op.or]: [
|
||||
{ documentId: document.id },
|
||||
...(document.collectionId
|
||||
? [
|
||||
{
|
||||
collectionId: document.collectionId,
|
||||
includeChildDocuments: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!shares.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const share of shares) {
|
||||
if (!share.allowSubscriptions) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subscriptions = await ShareSubscription.scope("active").findAll({
|
||||
where: { shareId: share.id },
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
// Throttle: only one notification per 6 hours
|
||||
if (
|
||||
subscription.lastNotifiedAt &&
|
||||
subscription.lastNotifiedAt > subHours(new Date(), 6)
|
||||
) {
|
||||
Logger.info(
|
||||
"processor",
|
||||
`suppressing share subscription notification to ${subscription.id} as recently notified`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseShareUrl = share.canonicalUrl;
|
||||
const shareUrl =
|
||||
share.collectionId && document.path
|
||||
? `${baseShareUrl.replace(/\/$/, "")}${document.path}`
|
||||
: baseShareUrl;
|
||||
|
||||
new ShareDocumentUpdatedEmail({
|
||||
to: subscription.email,
|
||||
shareSubscriptionId: subscription.id,
|
||||
documentTitle: document.titleWithDefault,
|
||||
shareUrl,
|
||||
}).schedule();
|
||||
|
||||
subscription.lastNotifiedAt = new Date();
|
||||
await subscription.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export const SharesUpdateSchema = BaseSchema.extend({
|
||||
includeChildDocuments: z.boolean().optional(),
|
||||
published: z.boolean().optional(),
|
||||
allowIndexing: z.boolean().optional(),
|
||||
allowSubscriptions: z.boolean().optional(),
|
||||
showLastUpdated: z.boolean().optional(),
|
||||
showTOC: z.boolean().optional(),
|
||||
urlId: z
|
||||
@@ -73,6 +74,7 @@ export const SharesCreateSchema = BaseSchema.extend({
|
||||
documentId: zodIdType().optional(),
|
||||
published: z.boolean().prefault(false),
|
||||
allowIndexing: z.boolean().optional(),
|
||||
allowSubscriptions: z.boolean().optional(),
|
||||
showLastUpdated: z.boolean().optional(),
|
||||
showTOC: z.boolean().optional(),
|
||||
urlId: z
|
||||
@@ -105,3 +107,34 @@ export const SharesSitemapSchema = BaseSchema.extend({
|
||||
});
|
||||
|
||||
export type SharesSitemapReq = z.infer<typeof SharesSitemapSchema>;
|
||||
|
||||
export const SharesSubscribeSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
shareId: z.string(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SharesSubscribeReq = z.infer<typeof SharesSubscribeSchema>;
|
||||
|
||||
export const SharesConfirmSubscriptionSchema = BaseSchema.extend({
|
||||
query: z.object({
|
||||
id: z.uuid(),
|
||||
token: z.string(),
|
||||
follow: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SharesConfirmSubscriptionReq = z.infer<
|
||||
typeof SharesConfirmSubscriptionSchema
|
||||
>;
|
||||
|
||||
export const SharesUnsubscribeSchema = BaseSchema.extend({
|
||||
query: z.object({
|
||||
id: z.uuid(),
|
||||
token: z.string(),
|
||||
follow: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SharesUnsubscribeReq = z.infer<typeof SharesUnsubscribeSchema>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import queryString from "query-string";
|
||||
import { randomString } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import { UserMembership, Share } from "@server/models";
|
||||
import { UserMembership, Share, ShareSubscription } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
buildDocument,
|
||||
@@ -1153,3 +1155,265 @@ describe("#shares.revoke", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.subscribe", () => {
|
||||
it("should create a subscription for a published share", async () => {
|
||||
const share = await buildShare();
|
||||
const res = await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "subscriber@example.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const subscription = await ShareSubscription.findOne({
|
||||
where: { shareId: share.id },
|
||||
});
|
||||
expect(subscription).not.toBeNull();
|
||||
expect(subscription!.email).toBe("subscriber@example.com");
|
||||
expect(subscription!.confirmedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize email fingerprint on create", async () => {
|
||||
const share = await buildShare();
|
||||
await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "First.Last+tag@Example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = await ShareSubscription.findOne({
|
||||
where: { shareId: share.id },
|
||||
});
|
||||
expect(subscription!.emailFingerprint).toBe(
|
||||
ShareSubscription.normalizeEmailFingerprint("First.Last+tag@Example.com")
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create duplicate subscriptions for same fingerprint", async () => {
|
||||
const share = await buildShare();
|
||||
await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "user@gmail.com",
|
||||
},
|
||||
});
|
||||
await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "u.s.e.r@gmail.com",
|
||||
},
|
||||
});
|
||||
|
||||
const count = await ShareSubscription.count({
|
||||
where: { shareId: share.id },
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should silently succeed for already confirmed subscription", async () => {
|
||||
const share = await buildShare();
|
||||
await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint:
|
||||
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow re-subscribing after unsubscribe", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint:
|
||||
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
unsubscribedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.unsubscribedAt).toBeNull();
|
||||
expect(subscription.confirmedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should fail for unpublished share", async () => {
|
||||
const share = await buildShare({ published: false });
|
||||
const res = await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
|
||||
it("should fail with invalid email", async () => {
|
||||
const share = await buildShare();
|
||||
const res = await server.post("/api/shares.subscribe", {
|
||||
body: {
|
||||
shareId: share.id,
|
||||
email: "not-an-email",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.confirmSubscription", () => {
|
||||
it("should confirm a subscription with valid token", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint: "user@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
const token = ShareSubscription.generateConfirmToken(subscription);
|
||||
const res = await server.get(
|
||||
`/api/shares.confirmSubscription?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token,
|
||||
follow: "true",
|
||||
})}`,
|
||||
{ redirect: "manual" }
|
||||
);
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain("notice=subscribed");
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.confirmedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should reject an invalid token", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint: "user@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
|
||||
const res = await server.get(
|
||||
`/api/shares.confirmSubscription?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token: "invalid-token",
|
||||
follow: "true",
|
||||
})}`,
|
||||
{ redirect: "manual" }
|
||||
);
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain("notice=invalid-auth");
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.confirmedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject an expired token", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint: "user@example.com",
|
||||
secret: randomString(32),
|
||||
});
|
||||
// Force updatedAt to 25 hours ago so the token is expired
|
||||
const expiredDate = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
||||
await ShareSubscription.update(
|
||||
{ createdAt: expiredDate, updatedAt: expiredDate },
|
||||
{ where: { id: subscription.id }, silent: true }
|
||||
);
|
||||
await subscription.reload();
|
||||
|
||||
const token = ShareSubscription.generateConfirmToken(subscription);
|
||||
const res = await server.get(
|
||||
`/api/shares.confirmSubscription?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token,
|
||||
follow: "true",
|
||||
})}`,
|
||||
{ redirect: "manual" }
|
||||
);
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain("notice=expired-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.unsubscribe", () => {
|
||||
it("should unsubscribe with valid token", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint: "user@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const token = ShareSubscription.generateUnsubscribeToken(subscription);
|
||||
const res = await server.get(
|
||||
`/api/shares.unsubscribe?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token,
|
||||
follow: "true",
|
||||
})}`,
|
||||
{ redirect: "manual" }
|
||||
);
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain("notice=unsubscribed");
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.unsubscribedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should reject an invalid token", async () => {
|
||||
const share = await buildShare();
|
||||
const subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email: "user@example.com",
|
||||
emailFingerprint: "user@example.com",
|
||||
secret: randomString(32),
|
||||
confirmedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.get(
|
||||
`/api/shares.unsubscribe?${queryString.stringify({
|
||||
id: subscription.id,
|
||||
token: "invalid-token",
|
||||
follow: "true",
|
||||
})}`,
|
||||
{ redirect: "manual" }
|
||||
);
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain("notice=invalid-auth");
|
||||
|
||||
await subscription.reload();
|
||||
expect(subscription.unsubscribedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,28 @@ import Router from "koa-router";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import type { FindOptions, WhereAttributeHash, WhereOptions } from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import { subMinutes } from "date-fns";
|
||||
import { randomString } from "@shared/random";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { AuthenticationError, NotFoundError } from "@server/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
InvalidRequestError,
|
||||
NotFoundError,
|
||||
} from "@server/errors";
|
||||
import ShareSubscriptionConfirmEmail from "@server/emails/templates/ShareSubscriptionConfirmEmail";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, User, Share, Team, Collection } from "@server/models";
|
||||
import {
|
||||
Document,
|
||||
User,
|
||||
Share,
|
||||
Team,
|
||||
Collection,
|
||||
ShareSubscription,
|
||||
} from "@server/models";
|
||||
import ShareSubscriptionHelper from "@server/models/helpers/ShareSubscriptionHelper";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import {
|
||||
presentShare,
|
||||
@@ -28,6 +43,8 @@ import {
|
||||
loadShareWithParent,
|
||||
} from "@server/commands/shareLoader";
|
||||
import shareDomains from "@server/middlewares/shareDomains";
|
||||
import env from "@server/env";
|
||||
import { safeEqual } from "@server/utils/crypto";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -245,6 +262,7 @@ router.post(
|
||||
urlId,
|
||||
includeChildDocuments,
|
||||
allowIndexing,
|
||||
allowSubscriptions,
|
||||
showLastUpdated,
|
||||
showTOC,
|
||||
} = ctx.input.body;
|
||||
@@ -282,6 +300,7 @@ router.post(
|
||||
published,
|
||||
includeChildDocuments,
|
||||
allowIndexing,
|
||||
allowSubscriptions,
|
||||
showLastUpdated,
|
||||
showTOC,
|
||||
urlId,
|
||||
@@ -312,6 +331,7 @@ router.post(
|
||||
published,
|
||||
urlId,
|
||||
allowIndexing,
|
||||
allowSubscriptions,
|
||||
showLastUpdated,
|
||||
showTOC,
|
||||
} = ctx.input.body;
|
||||
@@ -344,6 +364,9 @@ router.post(
|
||||
if (allowIndexing !== undefined) {
|
||||
share.allowIndexing = allowIndexing;
|
||||
}
|
||||
if (allowSubscriptions !== undefined) {
|
||||
share.allowSubscriptions = allowSubscriptions;
|
||||
}
|
||||
if (showLastUpdated !== undefined) {
|
||||
share.showLastUpdated = showLastUpdated;
|
||||
}
|
||||
@@ -412,4 +435,177 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"shares.subscribe",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
validate(T.SharesSubscribeSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.SharesSubscribeReq>) => {
|
||||
const { shareId, email } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// Validate the share exists and is published
|
||||
const { share, document } = await loadPublicShare({ id: shareId });
|
||||
|
||||
if (!share.allowSubscriptions) {
|
||||
throw InvalidRequestError("Subscriptions are not enabled for this share");
|
||||
}
|
||||
|
||||
const emailFingerprint = ShareSubscription.normalizeEmailFingerprint(email);
|
||||
|
||||
const existing = await ShareSubscription.findOne({
|
||||
where: { shareId: share.id, emailFingerprint },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
let subscription: ShareSubscription;
|
||||
|
||||
if (existing) {
|
||||
// Already confirmed and active — return success silently
|
||||
if (existing.isConfirmed && !existing.isUnsubscribed) {
|
||||
ctx.body = { success: true };
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsubscribed — allow re-subscribe with new confirmation
|
||||
if (existing.isUnsubscribed) {
|
||||
existing.unsubscribedAt = null;
|
||||
existing.confirmedAt = null;
|
||||
existing.lastNotifiedAt = null;
|
||||
existing.secret = randomString(32);
|
||||
existing.email = email;
|
||||
await existing.save({ transaction });
|
||||
} else if (existing.createdAt > subMinutes(new Date(), 60)) {
|
||||
// Recently created, not yet confirmed — don't spam
|
||||
ctx.body = { success: true };
|
||||
return;
|
||||
} else {
|
||||
// Expired or stale unconfirmed — regenerate
|
||||
existing.secret = randomString(32);
|
||||
existing.email = email;
|
||||
await existing.save({ transaction });
|
||||
}
|
||||
|
||||
subscription = existing;
|
||||
} else {
|
||||
subscription = await ShareSubscription.create({
|
||||
shareId: share.id,
|
||||
email,
|
||||
emailFingerprint,
|
||||
secret: randomString(32),
|
||||
ipAddress: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
const confirmUrl = ShareSubscriptionHelper.confirmUrl(subscription);
|
||||
const usePublicBranding =
|
||||
share.team?.getPreference(TeamPreference.PublicBranding) ?? false;
|
||||
new ShareSubscriptionConfirmEmail({
|
||||
to: email,
|
||||
documentTitle:
|
||||
document?.titleWithDefault ??
|
||||
share.document?.title ??
|
||||
share.collection?.name ??
|
||||
"",
|
||||
confirmUrl,
|
||||
teamName: usePublicBranding ? share.team?.name : undefined,
|
||||
}).schedule();
|
||||
|
||||
ctx.body = { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"shares.confirmSubscription",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
validate(T.SharesConfirmSubscriptionSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.SharesConfirmSubscriptionReq>) => {
|
||||
const { id, token, follow } = ctx.input.query;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// Anti-prefetch: prevent email clients from pre-fetching the link
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
|
||||
}
|
||||
|
||||
const subscription = await ShareSubscription.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (!subscription || subscription.isUnsubscribed) {
|
||||
ctx.redirect(`${env.URL}?notice=invalid-auth`);
|
||||
return;
|
||||
}
|
||||
|
||||
const share = await Share.findByPk(subscription.shareId);
|
||||
|
||||
if (!share?.allowSubscriptions) {
|
||||
ctx.redirect(`${env.URL}?notice=invalid-auth`);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedToken = ShareSubscription.generateConfirmToken(subscription);
|
||||
|
||||
if (!safeEqual(token, expectedToken)) {
|
||||
ctx.redirect(`${env.URL}?notice=invalid-auth`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.isTokenExpired && !subscription.isConfirmed) {
|
||||
ctx.redirect(`${env.URL}?notice=expired-token`);
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.confirmedAt = new Date();
|
||||
await subscription.save({ transaction });
|
||||
|
||||
const shareUrl = share?.canonicalUrl ?? env.URL;
|
||||
ctx.redirect(`${shareUrl}?notice=subscribed`);
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"shares.unsubscribe",
|
||||
rateLimiter(RateLimiterStrategy.TenPerMinute),
|
||||
validate(T.SharesUnsubscribeSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.SharesUnsubscribeReq>) => {
|
||||
const { id, token, follow } = ctx.input.query;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// Anti-prefetch: prevent email clients from pre-fetching the link
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
|
||||
}
|
||||
|
||||
const subscription = await ShareSubscription.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
ctx.redirect(`${env.URL}?notice=invalid-auth`);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedToken =
|
||||
ShareSubscription.generateUnsubscribeToken(subscription);
|
||||
|
||||
if (!safeEqual(token, expectedToken)) {
|
||||
ctx.redirect(`${env.URL}?notice=invalid-auth`);
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.unsubscribedAt = new Date();
|
||||
await subscription.save({ transaction });
|
||||
|
||||
const share = await Share.findByPk(subscription.shareId);
|
||||
const shareUrl = share?.canonicalUrl ?? env.URL;
|
||||
ctx.redirect(`${shareUrl}?notice=unsubscribed`);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
type ShareContextType = {
|
||||
shareId?: string;
|
||||
sharedTree?: NavigationNode;
|
||||
allowSubscriptions?: boolean;
|
||||
};
|
||||
|
||||
export const ShareContext = React.createContext<ShareContextType>({});
|
||||
|
||||
@@ -441,6 +441,8 @@
|
||||
"Publish to internet": "Publish to internet",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Email subscriptions": "Email subscriptions",
|
||||
"Allow viewers to subscribe and receive email notifications when documents are updated": "Allow viewers to subscribe and receive email notifications when documents are updated",
|
||||
"Show last modified": "Show last modified",
|
||||
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
|
||||
"Show table of contents": "Show table of contents",
|
||||
@@ -454,8 +456,13 @@
|
||||
"{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} people and {{ count2 }} groups added to the collection",
|
||||
"Switch to dark": "Switch to dark",
|
||||
"Switch to light": "Switch to light",
|
||||
"Subscribe to updates": "Subscribe to updates",
|
||||
"Add": "Add",
|
||||
"Add or invite": "Add or invite",
|
||||
"Something went wrong.": "Something went wrong.",
|
||||
"Check your email to confirm your subscription.": "Check your email to confirm your subscription.",
|
||||
"Get notified when this document is updated": "Get notified when this document is updated",
|
||||
"Email address": "Email address",
|
||||
"Viewer": "Viewer",
|
||||
"Editor": "Editor",
|
||||
"Suggestions for invitation": "Suggestions for invitation",
|
||||
@@ -480,6 +487,7 @@
|
||||
"Leave": "Leave",
|
||||
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
|
||||
"Allow viewers to subscribe and receive email notifications when this document is updated": "Allow viewers to subscribe and receive email notifications when this document is updated",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
|
||||
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
|
||||
"{{ count }} people added to the document": "{{ count }} people added to the document",
|
||||
@@ -1331,7 +1339,6 @@
|
||||
"Photo": "Photo",
|
||||
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
|
||||
"This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.",
|
||||
"Email address": "Email address",
|
||||
"Members and guests": "Members and guests",
|
||||
"No one": "No one",
|
||||
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
|
||||
@@ -1687,6 +1694,17 @@
|
||||
"This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.": "This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.",
|
||||
"We only send a reminder once.": "We only send a reminder once.",
|
||||
"If you haven't signed up yet, you can do so here": "If you haven't signed up yet, you can do so here",
|
||||
"\"{{ documentTitle }}\" updated": "\"{{ documentTitle }}\" updated",
|
||||
"\"{{ documentTitle }}\" has been updated.": "\"{{ documentTitle }}\" has been updated.",
|
||||
"A document you subscribed to has been updated.": "A document you subscribed to has been updated.",
|
||||
"Click below to view the latest version.": "Click below to view the latest version.",
|
||||
"Confirm your subscription": "Confirm your subscription",
|
||||
"Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.": "Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.",
|
||||
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.": "You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.",
|
||||
"Confirm Subscription": "Confirm Subscription",
|
||||
"This link will expire in 24 hours.": "This link will expire in 24 hours.",
|
||||
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.": "You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.",
|
||||
"Please confirm your subscription by clicking the button below.": "Please confirm your subscription by clicking the button below.",
|
||||
"Magic signin link": "Magic signin link",
|
||||
"Sign in verification code": "Sign in verification code",
|
||||
"Here’s your link to signin to {{ appName }}.": "Here’s your link to signin to {{ appName }}.",
|
||||
|
||||
Reference in New Issue
Block a user