mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3a8ca4e0 | |||
| bc00fd6f45 | |||
| 75c7819bc5 | |||
| 0410e099d8 | |||
| d85f4456d1 |
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QueryNotices.UnsubscribeCollection: {
|
||||
toast.success(
|
||||
t("Unsubscribed from collection", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, notice]);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "@server/env";
|
||||
import SubscriptionHelper from "./SubscriptionHelper";
|
||||
|
||||
describe("SubscriptionHelper", () => {
|
||||
describe("unsubscribeUrl", () => {
|
||||
it("should return a valid unsubscribe URL", () => {
|
||||
const userId = uuidv4();
|
||||
const documentId = uuidv4();
|
||||
|
||||
const unsubscribeUrl = SubscriptionHelper.unsubscribeUrl(
|
||||
userId,
|
||||
documentId
|
||||
);
|
||||
expect(unsubscribeUrl).toContain(`${env.URL}/api/subscriptions.delete`);
|
||||
expect(unsubscribeUrl).toContain(`userId=${userId}`);
|
||||
expect(unsubscribeUrl).toContain(`documentId=${documentId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import queryString from "query-string";
|
||||
import env from "@server/env";
|
||||
|
||||
/**
|
||||
@@ -15,12 +16,22 @@ export default class SubscriptionHelper {
|
||||
* @returns The unsubscribe URL
|
||||
*/
|
||||
public static unsubscribeUrl(userId: string, documentId: string) {
|
||||
return `${env.URL}/api/subscriptions.delete?token=${this.unsubscribeToken(
|
||||
const token = this.unsubscribeToken(userId, documentId);
|
||||
|
||||
return `${env.URL}/api/subscriptions.delete?${queryString.stringify({
|
||||
token,
|
||||
userId,
|
||||
documentId
|
||||
)}&userId=${userId}&documentId=${documentId}`;
|
||||
documentId,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token for unsubscribing a user from a document or collection.
|
||||
*
|
||||
* @param userId The user ID to unsubscribe
|
||||
* @param documentId The document ID to unsubscribe from
|
||||
* @returns The unsubscribe token
|
||||
*/
|
||||
public static unsubscribeToken(userId: string, documentId: string) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(`${userId}-${env.SECRET_KEY}-${documentId}`);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { Event } from "@server/models";
|
||||
import queryString from "query-string";
|
||||
import { QueryNotices, SubscriptionType } from "@shared/types";
|
||||
import { Event, Subscription } from "@server/models";
|
||||
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
|
||||
import {
|
||||
buildUser,
|
||||
buildSubscription,
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
buildDraftDocument,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
@@ -704,126 +707,220 @@ describe("#subscriptions.list", () => {
|
||||
});
|
||||
|
||||
describe("#subscriptions.delete", () => {
|
||||
it("should delete user's subscription", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
describe("GET", () => {
|
||||
it("should delete user's document subscription", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDraftDocument({
|
||||
userId: user.id,
|
||||
id: subscription.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.get(
|
||||
`/api/subscriptions.delete?${queryString.stringify({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
follow: true,
|
||||
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
|
||||
})}`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
`/home?notice=${QueryNotices.UnsubscribeDocument}`
|
||||
);
|
||||
expect(await Subscription.findByPk(subscription.id)).toBeNull();
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
it("should delete user's collection subscription", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
collectionId: document.collectionId,
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.ok).toEqual(true);
|
||||
expect(body.success).toEqual(true);
|
||||
const res = await server.get(
|
||||
`/api/subscriptions.delete?${queryString.stringify({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
follow: true,
|
||||
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
|
||||
})}`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
`/home?notice=${QueryNotices.UnsubscribeCollection}`
|
||||
);
|
||||
expect(await Subscription.findByPk(subscription.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("should not fail if subscription is already deleted", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
await subscription.destroy();
|
||||
|
||||
const res = await server.get(
|
||||
`/api/subscriptions.delete?${queryString.stringify({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
follow: true,
|
||||
token: SubscriptionHelper.unsubscribeToken(user.id, document.id),
|
||||
})}`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
expect(res.headers.get("location")).toContain(`/home`);
|
||||
expect(await Subscription.findByPk(subscription.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit event", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
describe("POST", () => {
|
||||
it("should delete user's document subscription", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
id: subscription.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const events = await Event.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0].name).toEqual("subscriptions.delete");
|
||||
expect(events[0].modelId).toEqual(subscription.id);
|
||||
expect(events[0].actorId).toEqual(user.id);
|
||||
expect(events[0].documentId).toEqual(document.id);
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.ok).toEqual(true);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("users should not be able to delete other's subscriptions on document", async () => {
|
||||
const subscriber0 = await buildUser();
|
||||
// `subscriber1` belongs to `subscriber0`'s team.
|
||||
const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
|
||||
|
||||
// `subscriber0` created a document.
|
||||
const document = await buildDocument({
|
||||
userId: subscriber0.id,
|
||||
teamId: subscriber0.teamId,
|
||||
});
|
||||
|
||||
// `subscriber0` wants to be notified about
|
||||
// changes on this document.
|
||||
await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: subscriber0.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
id: subscription.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.ok).toEqual(true);
|
||||
expect(body.success).toEqual(true);
|
||||
|
||||
const events = await Event.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0].name).toEqual("subscriptions.delete");
|
||||
expect(events[0].modelId).toEqual(subscription.id);
|
||||
expect(events[0].actorId).toEqual(user.id);
|
||||
expect(events[0].documentId).toEqual(document.id);
|
||||
});
|
||||
|
||||
// `subscriber1` wants to be notified about
|
||||
// changes on this document.
|
||||
const resp = await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: subscriber1.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
},
|
||||
it("should delete user's collection subscription", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({ userId: user.id });
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
id: subscription.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.ok).toEqual(true);
|
||||
expect(body.success).toEqual(true);
|
||||
|
||||
const events = await Event.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.length).toEqual(1);
|
||||
expect(events[0].name).toEqual("subscriptions.delete");
|
||||
expect(events[0].modelId).toEqual(subscription.id);
|
||||
expect(events[0].actorId).toEqual(user.id);
|
||||
expect(events[0].collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
const subscription1 = await resp.json();
|
||||
const subscription1Id = subscription1.data.id;
|
||||
it("users should not be able to delete other's subscriptions on document", async () => {
|
||||
const subscriber0 = await buildUser();
|
||||
// `subscriber1` belongs to `subscriber0`'s team.
|
||||
const subscriber1 = await buildUser({ teamId: subscriber0.teamId });
|
||||
|
||||
// `subscriber0` wants to change `subscriber1`'s
|
||||
// subscription for this document.
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
// `subscriber0`
|
||||
// `subscriber0` created a document.
|
||||
const document = await buildDocument({
|
||||
userId: subscriber0.id,
|
||||
// subscription id of `subscriber1`
|
||||
id: subscription1Id,
|
||||
token: subscriber0.getJwtToken(),
|
||||
},
|
||||
teamId: subscriber0.teamId,
|
||||
});
|
||||
|
||||
// `subscriber0` wants to be notified about
|
||||
// changes on this document.
|
||||
await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: subscriber0.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
},
|
||||
});
|
||||
|
||||
// `subscriber1` wants to be notified about
|
||||
// changes on this document.
|
||||
const resp = await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: subscriber1.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
},
|
||||
});
|
||||
|
||||
const subscription1 = await resp.json();
|
||||
const subscription1Id = subscription1.data.id;
|
||||
|
||||
// `subscriber0` wants to change `subscriber1`'s
|
||||
// subscription for this document.
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
body: {
|
||||
id: subscription1Id,
|
||||
token: subscriber0.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
// `subscriber0` should be unauthorized.
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.ok).toEqual(false);
|
||||
expect(body.error).toEqual("authorization_error");
|
||||
expect(body.message).toEqual("Authorization error");
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
// `subscriber0` should be unauthorized.
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.ok).toEqual(false);
|
||||
expect(body.error).toEqual("authorization_error");
|
||||
expect(body.message).toEqual("Authorization error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,14 +168,21 @@ router.get(
|
||||
return;
|
||||
}
|
||||
|
||||
const [subscription, user] = await Promise.all([
|
||||
const [documentSubscription, document, user] = await Promise.all([
|
||||
Subscription.findOne({
|
||||
where: {
|
||||
userId,
|
||||
documentId,
|
||||
},
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
}),
|
||||
Document.unscoped().findOne({
|
||||
attributes: ["collectionId"],
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
paranoid: false,
|
||||
transaction,
|
||||
}),
|
||||
User.scope("withTeam").findByPk(userId, {
|
||||
@@ -184,18 +191,41 @@ router.get(
|
||||
}),
|
||||
]);
|
||||
|
||||
authorize(user, "delete", subscription);
|
||||
const context = createContext({
|
||||
user,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await subscription.destroyWithCtx(
|
||||
createContext({
|
||||
user,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
const collectionSubscription = document?.collectionId
|
||||
? await Subscription.findOne({
|
||||
where: {
|
||||
userId,
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collectionSubscription) {
|
||||
authorize(user, "delete", collectionSubscription);
|
||||
await collectionSubscription.destroyWithCtx(context);
|
||||
}
|
||||
|
||||
if (documentSubscription) {
|
||||
authorize(user, "delete", documentSubscription);
|
||||
await documentSubscription.destroyWithCtx(context);
|
||||
}
|
||||
|
||||
ctx.redirect(
|
||||
`${user.team.url}/home?notice=${QueryNotices.UnsubscribeDocument}`
|
||||
`${user.team.url}/home?notice=${
|
||||
collectionSubscription
|
||||
? QueryNotices.UnsubscribeCollection
|
||||
: documentSubscription
|
||||
? QueryNotices.UnsubscribeDocument
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -507,6 +507,7 @@
|
||||
"None": "None",
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Unsubscribed from collection": "Unsubscribed from collection",
|
||||
"Account": "Account",
|
||||
"API Keys": "API Keys",
|
||||
"Details": "Details",
|
||||
|
||||
@@ -415,6 +415,7 @@ export type UnfurlResponse = {
|
||||
|
||||
export enum QueryNotices {
|
||||
UnsubscribeDocument = "unsubscribe-document",
|
||||
UnsubscribeCollection = "unsubscribe-collection",
|
||||
}
|
||||
|
||||
export type JSONValue =
|
||||
|
||||
Reference in New Issue
Block a user