Improve scoping of public share subscriptions (#11932)

* Improve scoping of public share subscriptions

* fix: Add missing transaction, includeChildDocuments check, and test documentId

- Pass { transaction } to ShareSubscription.create in the subscribe handler
  so the insert runs atomically with the duplicate-check findOne/lock
- Skip ancestor-scoped subscription notifications when the share has
  includeChildDocuments=false, preventing notifications for inaccessible docs
- Add required documentId field to all share subscription tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Resolve type error for nullable share.documentId in tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* JSDoc

* Hide subscription option for logged-in users

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-02 08:08:05 -04:00
committed by GitHub
parent 9516459d31
commit 12c71f267e
14 changed files with 393 additions and 107 deletions
@@ -50,7 +50,13 @@ export const AppearanceAction = observer(() => {
);
});
export function SubscribeAction({ shareId }: { shareId: string }) {
export function SubscribeAction({
shareId,
documentId,
}: {
shareId: string;
documentId?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -68,7 +74,7 @@ export function SubscribeAction({ shareId }: { shareId: string }) {
</PopoverTrigger>
</Tooltip>
<PopoverContent side="bottom" align="end" width={340}>
<ShareSubscribeForm shareId={shareId} />
<ShareSubscribeForm shareId={shareId} documentId={documentId} />
</PopoverContent>
</Popover>
</Action>
@@ -12,7 +12,13 @@ import { client } from "~/utils/ApiClient";
/**
* Subscribe form content displayed inside the popover.
*/
export function ShareSubscribeForm({ shareId }: { shareId: string }) {
export function ShareSubscribeForm({
shareId,
documentId,
}: {
shareId: string;
documentId?: string;
}) {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
@@ -25,16 +31,16 @@ export function ShareSubscribeForm({ shareId }: { shareId: string }) {
ev.preventDefault();
setStatus("loading");
try {
await client.post("/shares.subscribe", { shareId, email });
await client.post("/shares.subscribe", { shareId, documentId, email });
setStatus("success");
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : t("Something went wrong.")
err instanceof Error ? err.message : t("Something went wrong")
);
setStatus("error");
}
},
[shareId, email]
[shareId, documentId, email]
);
const handleChange = useCallback(
@@ -52,7 +58,7 @@ export function ShareSubscribeForm({ shareId }: { shareId: string }) {
return (
<FormContainer>
<Text type="tertiary" size="small">
{t("Check your email to confirm your subscription.")}
{t("Check your email to confirm your subscription")}.
</Text>
</FormContainer>
);
+24 -11
View File
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { QueryNotices } from "@shared/types";
import useQuery from "./useQuery";
@@ -11,28 +12,40 @@ import useQuery from "./useQuery";
*/
export default function useQueryNotices() {
const query = useQuery();
const history = useHistory();
const { t } = useTranslation();
const notice = query.get("notice") as QueryNotices;
useEffect(() => {
let message: string | undefined;
switch (notice) {
case QueryNotices.UnsubscribeDocument: {
toast.success(
t("Unsubscribed from document", {
type: "success",
})
);
message = t("Unsubscribed from document");
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
message = t("Unsubscribed from collection");
break;
}
case QueryNotices.Subscribed: {
message = t("Subscription successful");
break;
}
default:
}
}, [t, notice]);
if (message) {
toast.success(message);
// Remove the notice param from the URL to prevent duplicate toasts
const params = new URLSearchParams(window.location.search);
params.delete("notice");
const search = params.toString();
history.replace({
pathname: window.location.pathname,
search: search ? `?${search}` : "",
});
}
}, [t, notice, history]);
}
+2 -2
View File
@@ -213,8 +213,8 @@ function DocumentHeader({
}
actions={
<>
{allowSubscriptions !== false && (
<SubscribeAction shareId={shareId} />
{allowSubscriptions !== false && !user && (
<SubscribeAction shareId={shareId} documentId={document.id} />
)}
<AppearanceAction />
{can.update && !isEditing ? editAction : <div />}
@@ -0,0 +1,62 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.sequelize.transaction(async (transaction) => {
// Remove all existing records as they lack document scope
await queryInterface.bulkDelete(
"share_subscriptions",
{},
{ transaction }
);
await queryInterface.addColumn(
"share_subscriptions",
"documentId",
{
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
key: "id",
},
onDelete: "CASCADE",
},
{ transaction }
);
// Replace old unique index with one that includes documentId
await queryInterface.removeIndex(
"share_subscriptions",
["shareId", "emailFingerprint"],
{ transaction }
);
await queryInterface.addIndex(
"share_subscriptions",
["shareId", "documentId", "emailFingerprint"],
{ unique: true, transaction }
);
});
},
async down(queryInterface) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.removeIndex(
"share_subscriptions",
["shareId", "documentId", "emailFingerprint"],
{ transaction }
);
await queryInterface.addIndex(
"share_subscriptions",
["shareId", "emailFingerprint"],
{ unique: true, transaction }
);
await queryInterface.removeColumn("share_subscriptions", "documentId", {
transaction,
});
});
},
};
+70 -12
View File
@@ -1,13 +1,18 @@
import { randomString } from "@shared/random";
import { buildShare } from "@server/test/factories";
import { buildDocument, 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 document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
@@ -19,9 +24,14 @@ describe("ShareSubscription", () => {
});
it("should produce different tokens for confirm vs unsubscribe", async () => {
const share = await buildShare();
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
@@ -34,15 +44,22 @@ describe("ShareSubscription", () => {
});
it("should produce different tokens for different secrets", async () => {
const share = await buildShare();
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
const sub1 = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "test@example.com",
emailFingerprint: "test@example.com",
secret: randomString(32),
});
const document2 = await buildDocument({ teamId: document.teamId });
const sub2 = await ShareSubscription.create({
shareId: share.id,
documentId: document2.id,
email: "test2@example.com",
emailFingerprint: "test2@example.com",
secret: randomString(32),
@@ -149,12 +166,17 @@ describe("ShareSubscription", () => {
});
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++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
@@ -162,9 +184,15 @@ describe("ShareSubscription", () => {
});
}
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await expect(
ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
@@ -174,11 +202,15 @@ describe("ShareSubscription", () => {
});
it("should not count subscriptions from different IPs", async () => {
const share = await buildShare();
for (let i = 0; i < 3; i++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
@@ -186,9 +218,15 @@ describe("ShareSubscription", () => {
});
}
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await expect(
ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "user3@example.com",
emailFingerprint: "user3@example.com",
secret: randomString(32),
@@ -198,15 +236,28 @@ describe("ShareSubscription", () => {
});
it("should not count duplicate fingerprints from the same IP", async () => {
const share1 = await buildShare();
const share2 = await buildShare();
const document1 = await buildDocument();
const share1 = await buildShare({
documentId: document1.id,
teamId: document1.teamId,
});
const document2 = await buildDocument();
const share2 = await buildShare({
documentId: document2.id,
teamId: document2.teamId,
});
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();
const doc = await buildDocument();
const share = await buildShare({
documentId: doc.id,
teamId: doc.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: doc.id,
email: "same@example.com",
emailFingerprint: "same@example.com",
secret: randomString(32),
@@ -216,8 +267,10 @@ describe("ShareSubscription", () => {
// 2 more unique fingerprints — total distinct = 3
for (let i = 0; i < 2; i++) {
const doc = await buildDocument();
await ShareSubscription.create({
shareId: share1.id,
documentId: doc.id,
email: `other${i}@example.com`,
emailFingerprint: `other${i}@example.com`,
secret: randomString(32),
@@ -229,6 +282,7 @@ describe("ShareSubscription", () => {
await expect(
ShareSubscription.create({
shareId: share2.id,
documentId: document2.id,
email: "blocked@example.com",
emailFingerprint: "blocked@example.com",
secret: randomString(32),
@@ -238,11 +292,15 @@ describe("ShareSubscription", () => {
});
it("should skip the check if ipAddress is null", async () => {
const share = await buildShare();
for (let i = 0; i < 6; i++) {
const document = await buildDocument();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: `user${i}@example.com`,
emailFingerprint: `user${i}@example.com`,
secret: randomString(32),
+24
View File
@@ -12,10 +12,15 @@ import {
} from "sequelize-typescript";
import { subHours } from "date-fns";
import { ValidationError } from "@server/errors";
import Document from "./Document";
import Share from "./Share";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
/**
* A subscription to email notifications for updates to a publicly shared
* document and its descendants.
*/
@Scopes(() => ({
active: {
where: {
@@ -37,15 +42,27 @@ class ShareSubscription extends IdModel<
@Column(DataType.UUID)
shareId: string;
/** The document to scope notifications to (the document and its descendants). */
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
/** The subscribed email */
@Column(DataType.STRING)
email: string;
/** Normalized email fingerprint helps to improve spam detection through removal of common bypasses */
@Column(DataType.STRING)
emailFingerprint: string;
/** Signing secret for subscribe/unsubscribe links */
@Column(DataType.STRING)
secret: string;
/** IP address of the user that subscribed */
@Column(DataType.STRING(45))
ipAddress: string | null;
@@ -61,6 +78,13 @@ class ShareSubscription extends IdModel<
/** Maximum number of unique email subscriptions allowed per IP address. */
static maxSubscriptionsPerIP = 3;
/**
* Enforce a per-IP rate limit on subscription creation to prevent abuse.
*
* @param model The subscription being created.
* @param options The save options including the current transaction.
* @throws when the IP has reached the maximum number of subscriptions.
*/
@BeforeCreate
static async checkIPLimit(
model: ShareSubscription,
@@ -22,6 +22,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -51,6 +52,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -79,6 +81,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -109,6 +112,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -139,6 +143,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -170,6 +175,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -199,6 +205,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "subscriber@example.com",
emailFingerprint: "subscriber@example.com",
secret: randomString(32),
@@ -232,6 +239,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "sub1@example.com",
emailFingerprint: "sub1@example.com",
secret: randomString(32),
@@ -239,6 +247,7 @@ describe("ShareSubscriptionNotificationsTask", () => {
});
await ShareSubscription.create({
shareId: share.id,
documentId: document.id,
email: "sub2@example.com",
emailFingerprint: "sub2@example.com",
secret: randomString(32),
@@ -274,4 +283,75 @@ describe("ShareSubscriptionNotificationsTask", () => {
expect(spy).not.toHaveBeenCalled();
});
it("should send when child document is updated and subscription is scoped to parent", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const parent = await buildDocument();
const child = await buildDocument({
parentDocumentId: parent.id,
collectionId: parent.collectionId,
teamId: parent.teamId,
});
const share = await buildShare({
documentId: parent.id,
teamId: parent.teamId,
includeChildDocuments: true,
});
await ShareSubscription.create({
shareId: share.id,
documentId: parent.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: child.id,
teamId: child.teamId,
actorId: child.createdById,
modelId: "revision-id",
ip,
});
expect(spy).toHaveBeenCalledTimes(1);
});
it("should not send when updated document is outside subscription scope", async () => {
const spy = jest.spyOn(ShareDocumentUpdatedEmail.prototype, "schedule");
const parent = await buildDocument();
const sibling = await buildDocument({
collectionId: parent.collectionId,
teamId: parent.teamId,
});
const share = await buildShare({
documentId: parent.id,
teamId: parent.teamId,
includeChildDocuments: true,
});
await ShareSubscription.create({
shareId: share.id,
documentId: parent.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: sibling.id,
teamId: sibling.teamId,
actorId: sibling.createdById,
modelId: "revision-id",
ip,
});
expect(spy).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,4 @@
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";
@@ -13,67 +12,76 @@ export default class ShareSubscriptionNotificationsTask extends BaseTask<Revisio
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;
// Collect the document's ID and all ancestor IDs by walking up the tree.
// A subscription scoped to any of these documents covers the updated one.
const scopeIds: string[] = [document.id];
let parentId = document.parentDocumentId;
while (parentId) {
scopeIds.push(parentId);
const parent = await Document.findByPk(parentId, {
attributes: ["id", "parentDocumentId"],
});
if (!parent) {
break;
}
parentId = parent.parentDocumentId;
}
for (const share of shares) {
if (!share.allowSubscriptions) {
// Find all active subscriptions scoped to this document or any ancestor,
// joined to a published share that allows subscriptions.
const subscriptions = await ShareSubscription.scope("active").findAll({
where: { documentId: scopeIds },
include: [
{
model: Share.unscoped(),
required: true,
where: {
published: true,
revokedAt: null,
allowSubscriptions: true,
},
include: [{ association: "team", required: true }],
},
],
});
for (const subscription of subscriptions) {
// Skip ancestor-scoped subscriptions when the share doesn't include
// child documents — the updated document wouldn't be accessible.
if (
subscription.documentId !== document.id &&
!subscription.share.includeChildDocuments
) {
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();
// 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 = subscription.share.canonicalUrl;
const shareUrl =
document.id !== subscription.share.documentId && 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();
}
}
+1
View File
@@ -111,6 +111,7 @@ export type SharesSitemapReq = z.infer<typeof SharesSitemapSchema>;
export const SharesSubscribeSchema = BaseSchema.extend({
body: z.object({
shareId: z.string(),
documentId: z.uuid(),
email: z.string().email(),
}),
});
+15
View File
@@ -1162,6 +1162,7 @@ describe("#shares.subscribe", () => {
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "subscriber@example.com",
},
});
@@ -1182,6 +1183,7 @@ describe("#shares.subscribe", () => {
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "First.Last+tag@Example.com",
},
});
@@ -1199,12 +1201,14 @@ describe("#shares.subscribe", () => {
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "user@gmail.com",
},
});
await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "u.s.e.r@gmail.com",
},
});
@@ -1219,6 +1223,7 @@ describe("#shares.subscribe", () => {
const share = await buildShare();
await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint:
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
@@ -1229,6 +1234,7 @@ describe("#shares.subscribe", () => {
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
},
});
@@ -1241,6 +1247,7 @@ describe("#shares.subscribe", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint:
ShareSubscription.normalizeEmailFingerprint("user@example.com"),
@@ -1252,6 +1259,7 @@ describe("#shares.subscribe", () => {
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
},
});
@@ -1267,6 +1275,7 @@ describe("#shares.subscribe", () => {
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
},
});
@@ -1278,6 +1287,7 @@ describe("#shares.subscribe", () => {
const res = await server.post("/api/shares.subscribe", {
body: {
shareId: share.id,
documentId: share.documentId!,
email: "not-an-email",
},
});
@@ -1290,6 +1300,7 @@ describe("#shares.confirmSubscription", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
@@ -1315,6 +1326,7 @@ describe("#shares.confirmSubscription", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
@@ -1339,6 +1351,7 @@ describe("#shares.confirmSubscription", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
@@ -1370,6 +1383,7 @@ describe("#shares.unsubscribe", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
@@ -1396,6 +1410,7 @@ describe("#shares.unsubscribe", () => {
const share = await buildShare();
const subscription = await ShareSubscription.create({
shareId: share.id,
documentId: share.documentId!,
email: "user@example.com",
emailFingerprint: "user@example.com",
secret: randomString(32),
+28 -16
View File
@@ -441,13 +441,14 @@ router.post(
validate(T.SharesSubscribeSchema),
transaction(),
async (ctx: APIContext<T.SharesSubscribeReq>) => {
const { shareId, email } = ctx.input.body;
const { shareId, documentId, email } = ctx.input.body;
const { transaction } = ctx.state;
const team = await getTeamFromContext(ctx, { includeStateCookie: false });
// Validate the share exists and is published
const { share, document } = await loadPublicShare({
id: shareId,
documentId,
teamId: team?.id,
});
@@ -458,7 +459,7 @@ router.post(
const emailFingerprint = ShareSubscription.normalizeEmailFingerprint(email);
const existing = await ShareSubscription.findOne({
where: { shareId: share.id, emailFingerprint },
where: { shareId: share.id, documentId, emailFingerprint },
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -493,13 +494,17 @@ router.post(
subscription = existing;
} else {
subscription = await ShareSubscription.create({
shareId: share.id,
email,
emailFingerprint,
secret: randomString(32),
ipAddress: ctx.request.ip,
});
subscription = await ShareSubscription.create(
{
shareId: share.id,
documentId,
email,
emailFingerprint,
secret: randomString(32),
ipAddress: ctx.request.ip,
},
{ transaction }
);
}
const confirmUrl = ShareSubscriptionHelper.confirmUrl(subscription);
@@ -507,11 +512,7 @@ router.post(
share.team?.getPreference(TeamPreference.PublicBranding) ?? false;
new ShareSubscriptionConfirmEmail({
to: email,
documentTitle:
document?.titleWithDefault ??
share.document?.title ??
share.collection?.name ??
"",
documentTitle: document?.titleWithDefault ?? "",
confirmUrl,
teamName: usePublicBranding ? share.team?.name : undefined,
}).schedule();
@@ -566,8 +567,19 @@ router.get(
subscription.confirmedAt = new Date();
await subscription.save({ transaction });
const shareUrl = share?.canonicalUrl ?? env.URL;
ctx.redirect(`${shareUrl}?notice=subscribed`);
let redirectUrl = share?.canonicalUrl ?? env.URL;
if (
subscription.documentId &&
subscription.documentId !== share.documentId
) {
const doc = await Document.findByPk(subscription.documentId);
if (doc?.path) {
redirectUrl = `${redirectUrl.replace(/\/$/, "")}${doc.path}`;
}
}
ctx.redirect(`${redirectUrl}?notice=subscribed`);
}
);
+3 -3
View File
@@ -459,8 +459,8 @@
"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.",
"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",
@@ -681,6 +681,7 @@
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
"Subscription successful": "Subscription successful",
"Account": "Account",
"API & Access": "API & Access",
"Details": "Details",
@@ -886,7 +887,6 @@
"Your account has been suspended": "Your account has been suspended",
"Warning Sign": "Warning Sign",
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
"Something went wrong": "Something went wrong",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.",
"Created by me": "Created by me",
"Weird, this shouldn't ever be empty": "Weird, this shouldn't ever be empty",
+1
View File
@@ -673,6 +673,7 @@ export type UnfurlResponse = {
export enum QueryNotices {
UnsubscribeDocument = "unsubscribe-document",
UnsubscribeCollection = "unsubscribe-collection",
Subscribed = "subscribed",
}
export type JSONValue =