Skip to content

Commit 8aa9a1d

Browse files
committed
Fix Slack file upload review issues
1 parent 728c4e3 commit 8aa9a1d

3 files changed

Lines changed: 130 additions & 18 deletions

File tree

apps/web/app/docs/configuration/page.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,25 @@ slack:
216216
name: My Slack App
217217
redirect_uris:
218218
- http://localhost:3000/api/auth/callback/slack
219+
scopes:
220+
- chat:write
221+
- channels:read
222+
- users.profile:read
223+
- users.profile:write
224+
- users:write
225+
- files:read
226+
- files:write
227+
tokens:
228+
- token: xoxb-local-test
229+
user: developer
230+
scopes:
231+
- chat:write
232+
- channels:read
233+
- users.profile:read
234+
- users.profile:write
235+
- users:write
236+
- files:read
237+
- files:write
219238
incoming_webhooks:
220239
- channel: general
221240
label: CI Notifications

packages/@emulators/slack/src/__tests__/slack.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3288,6 +3288,66 @@ describe("Slack plugin - files", () => {
32883288
expect(ss.messages.all()).toHaveLength(0);
32893289
});
32903290

3291+
it("does not create direct messages when file completion validation fails", async () => {
3292+
insertSlackTestUser(store, "U000000002", "file-dm-target");
3293+
3294+
const uploadRes = await app.request(`${base}/api/files.getUploadURLExternal`, {
3295+
method: "POST",
3296+
headers: authHeaders(),
3297+
body: JSON.stringify({ filename: "dm-failure.txt", length: 7 }),
3298+
});
3299+
const upload = (await uploadRes.json()) as any;
3300+
await app.request(upload.upload_url, { method: "POST", body: "failure" });
3301+
3302+
const completeRes = await app.request(`${base}/api/files.completeUploadExternal`, {
3303+
method: "POST",
3304+
headers: authHeaders(),
3305+
body: JSON.stringify({
3306+
files: [{ id: upload.file_id, title: "DM Failure" }],
3307+
channel_id: "U000000002",
3308+
blocks: "not-json",
3309+
}),
3310+
});
3311+
const completed = (await completeRes.json()) as any;
3312+
expect(completed.ok).toBe(false);
3313+
expect(completed.error).toBe("invalid_blocks");
3314+
3315+
const ss = getSlackStore(store);
3316+
expect(ss.channels.all().filter((channel) => channel.is_im)).toHaveLength(0);
3317+
expect(ss.files.findOneBy("file_id", upload.file_id)).toBeUndefined();
3318+
expect(ss.fileUploadSessions.findOneBy("file_id", upload.file_id)?.completed).toBe(false);
3319+
});
3320+
3321+
it("deduplicates resolved file share channels", async () => {
3322+
const channel = getSlackStore(store).channels.findOneBy("name", "general")!.channel_id;
3323+
3324+
const uploadRes = await app.request(`${base}/api/files.getUploadURLExternal`, {
3325+
method: "POST",
3326+
headers: authHeaders(),
3327+
body: JSON.stringify({ filename: "dedupe.txt", length: 6 }),
3328+
});
3329+
const upload = (await uploadRes.json()) as any;
3330+
await app.request(upload.upload_url, { method: "POST", body: "dedupe" });
3331+
3332+
const completeRes = await app.request(`${base}/api/files.completeUploadExternal`, {
3333+
method: "POST",
3334+
headers: authHeaders(),
3335+
body: JSON.stringify({
3336+
files: [{ id: upload.file_id, title: "Dedupe" }],
3337+
channel_id: channel,
3338+
channels: "general",
3339+
}),
3340+
});
3341+
const completed = (await completeRes.json()) as any;
3342+
expect(completed.ok).toBe(true);
3343+
3344+
const ss = getSlackStore(store);
3345+
const messages = ss.messages.findBy("channel_id", channel);
3346+
const file = ss.files.findOneBy("file_id", upload.file_id)!;
3347+
expect(messages).toHaveLength(1);
3348+
expect(file.shares.public?.[channel]).toHaveLength(1);
3349+
});
3350+
32913351
it("enforces file scopes in strict mode", async () => {
32923352
store.setData("slack.strict_scopes", true);
32933353
tokenMap.set("xoxb-files-read-token", { login: "U000000001", id: 1, scopes: ["files:read"] });

packages/@emulators/slack/src/routes/files.ts

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ export function filesRoutes(ctx: RouteContext): void {
7575
.channels.all()
7676
.find((ch) => !ch.is_im && !ch.is_mpim && ch.name === channel);
7777

78+
type ShareTargetRef = { key: string; channel?: SlackChannel; directUserId?: string };
79+
80+
const findDirectMessage = (authUserId: string, userId: string) => {
81+
const members = [authUserId, userId].sort();
82+
return ss()
83+
.channels.all()
84+
.find(
85+
(ch) =>
86+
ch.is_im && ch.members.length === members.length && [...ch.members].sort().join(",") === members.join(","),
87+
);
88+
};
89+
7890
const findOrCreateDirectMessage = (authUser: { login: string }, userId: string) => {
7991
const targetUser = ss().users.findOneBy("user_id", userId);
8092
if (!targetUser || targetUser.deleted) return undefined;
@@ -83,12 +95,7 @@ export function filesRoutes(ctx: RouteContext): void {
8395
if (targetUser.user_id === authUserId) return undefined;
8496

8597
const members = [authUserId, targetUser.user_id].sort();
86-
const existing = ss()
87-
.channels.all()
88-
.find(
89-
(ch) =>
90-
ch.is_im && ch.members.length === members.length && [...ch.members].sort().join(",") === members.join(","),
91-
);
98+
const existing = findDirectMessage(authUserId, targetUser.user_id);
9299
if (existing) return existing;
93100

94101
const team = ss().teams.all()[0];
@@ -113,8 +120,21 @@ export function filesRoutes(ctx: RouteContext): void {
113120
});
114121
};
115122

116-
const findShareTarget = (authUser: { login: string }, channel: string) =>
117-
findChannel(channel) ?? (channel.startsWith("U") ? findOrCreateDirectMessage(authUser, channel) : undefined);
123+
const resolveShareTarget = (authUser: { login: string }, channel: string): ShareTargetRef | undefined => {
124+
const existingChannel = findChannel(channel);
125+
if (existingChannel) return { key: existingChannel.channel_id, channel: existingChannel };
126+
if (!channel.startsWith("U")) return undefined;
127+
128+
const targetUser = ss().users.findOneBy("user_id", channel);
129+
if (!targetUser || targetUser.deleted) return undefined;
130+
131+
const authUserId = getAuthUserId(authUser);
132+
if (targetUser.user_id === authUserId) return undefined;
133+
134+
const existingDirectMessage = findDirectMessage(authUserId, targetUser.user_id);
135+
if (existingDirectMessage) return { key: existingDirectMessage.channel_id, channel: existingDirectMessage };
136+
return { key: `user:${targetUser.user_id}`, directUserId: targetUser.user_id };
137+
};
118138

119139
app.post("/api/files.getUploadURLExternal", async (c) => {
120140
const authUser = c.get("authUser");
@@ -179,16 +199,6 @@ export function filesRoutes(ctx: RouteContext): void {
179199
}
180200

181201
const authUserId = getAuthUserId(authUser);
182-
const rawChannelIds = parseDestinationChannels(body.channel_id, body.channels);
183-
const targets: SlackChannel[] = [];
184-
for (const channelId of rawChannelIds) {
185-
const channel = findShareTarget(authUser, channelId);
186-
if (!channel) return slackError(c, "channel_not_found");
187-
if (channel.is_archived) return slackError(c, "is_archived");
188-
if (!canReadConversation(channel, getAuthSlackUser(authUser), authUserId)) return slackError(c, "not_in_channel");
189-
targets.push(channel);
190-
}
191-
192202
const initialComment = typeof body.initial_comment === "string" ? body.initial_comment : "";
193203
const threadTs = typeof body.thread_ts === "string" ? body.thread_ts : undefined;
194204
const blocks = parseBlocks(body.blocks);
@@ -203,6 +213,29 @@ export function filesRoutes(ctx: RouteContext): void {
203213
requestedSessions.push(session);
204214
}
205215

216+
const rawChannelIds = parseDestinationChannels(body.channel_id, body.channels);
217+
const targetRefs: ShareTargetRef[] = [];
218+
const targetKeys = new Set<string>();
219+
for (const channelId of rawChannelIds) {
220+
const target = resolveShareTarget(authUser, channelId);
221+
if (!target) return slackError(c, "channel_not_found");
222+
if (target.channel?.is_archived) return slackError(c, "is_archived");
223+
if (target.channel && !canReadConversation(target.channel, getAuthSlackUser(authUser), authUserId)) {
224+
return slackError(c, "not_in_channel");
225+
}
226+
if (!targetKeys.has(target.key)) {
227+
targetKeys.add(target.key);
228+
targetRefs.push(target);
229+
}
230+
}
231+
232+
const targets: SlackChannel[] = [];
233+
for (const target of targetRefs) {
234+
const channel = target.channel ?? findOrCreateDirectMessage(authUser, target.directUserId ?? "");
235+
if (!channel) return slackError(c, "channel_not_found");
236+
targets.push(channel);
237+
}
238+
206239
const completedFiles: SlackFile[] = [];
207240
for (let index = 0; index < requestedFiles.length; index++) {
208241
const requestedFile = requestedFiles[index];

0 commit comments

Comments
 (0)