this repo has no description

feat: add browser tokens for channel manager checks

dunkirk.sh 7c725e6c 0fb23519

verified
+134 -13
+8 -1
.env.example
··· 2 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 3 SLACK_SIGNING_SECRET=your-signing-secret-here 4 5 # IRC Configuration 6 IRC_NICK=slackbridge 7 8 # Admin users (comma-separated Slack user IDs) 9 - ADMINS=U1234567890 10 11 # Hack Club CDN Token (for file uploads) 12 CDN_TOKEN=your-cdn-token-here
··· 2 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 3 SLACK_SIGNING_SECRET=your-signing-secret-here 4 5 + # Slack workspace URL (for admin API calls) 6 + SLACK_API_URL=https://hackclub.enterprise.slack.com 7 + 8 + # Optional: For channel manager permission checks 9 + SLACK_USER_COOKIE=your-slack-cookie-here 10 + SLACK_USER_TOKEN=your-user-token-here 11 + 12 # IRC Configuration 13 IRC_NICK=slackbridge 14 15 # Admin users (comma-separated Slack user IDs) 16 + ADMINS=U1234567890,U0987654321 17 18 # Hack Club CDN Token (for file uploads) 19 CDN_TOKEN=your-cdn-token-here
+11 -1
README.md
··· 31 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 32 SLACK_SIGNING_SECRET=your-signing-secret-here 33 34 # IRC Configuration 35 IRC_NICK=slackbridge 36 37 # Admin users (comma-separated Slack user IDs) 38 - ADMINS=U1234567890 39 40 # Hack Club CDN Token (for file uploads) 41 CDN_TOKEN=your-cdn-token-here 42 43 # Server Configuration (optional) 44 PORT=3000 45 ``` 46 47 See `.env.example` for a template.
··· 31 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 32 SLACK_SIGNING_SECRET=your-signing-secret-here 33 34 + # Slack workspace URL (for admin API calls) 35 + SLACK_API_URL=https://hackclub.enterprise.slack.com 36 + 37 + # Optional: For channel manager permission checks 38 + SLACK_USER_COOKIE=your-slack-cookie-here 39 + SLACK_USER_TOKEN=your-user-token-here 40 + 41 # IRC Configuration 42 IRC_NICK=slackbridge 43 44 # Admin users (comma-separated Slack user IDs) 45 + ADMINS=U1234567890,U0987654321 46 47 # Hack Club CDN Token (for file uploads) 48 CDN_TOKEN=your-cdn-token-here 49 50 # Server Configuration (optional) 51 PORT=3000 52 + 53 + # Note: Channel and user mappings are now stored in the SQLite database (bridge.db) 54 + # Use the API or database tools to manage mappings 55 ``` 56 57 See `.env.example` for a template.
+23 -1
src/commands.ts
··· 1 import type { AnyMessageBlock, Block, BlockElement } from "slack-edge"; 2 import { channelMappings, userMappings } from "./db"; 3 import { slackApp, ircClient } from "./index"; 4 5 export function registerCommands() { 6 // Link Slack channel to IRC channel ··· 59 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value; 60 // @ts-expect-error 61 const slackChannelId = payload.actions?.[0]?.value; 62 if (!context.respond) { 63 return; 64 } ··· 72 return; 73 } 74 75 try { 76 channelMappings.create(slackChannelId, ircChannel); 77 ircClient.join(ircChannel); ··· 102 // Unlink Slack channel from IRC 103 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => { 104 const slackChannelId = payload.channel_id; 105 const mapping = channelMappings.getBySlackChannel(slackChannelId); 106 107 if (!mapping) { ··· 112 return; 113 } 114 115 context.respond({ 116 response_type: "ephemeral", 117 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?", ··· 147 ], 148 }, 149 ], 150 - replace_original: true, 151 }); 152 }); 153
··· 1 import type { AnyMessageBlock, Block, BlockElement } from "slack-edge"; 2 import { channelMappings, userMappings } from "./db"; 3 import { slackApp, ircClient } from "./index"; 4 + import { canManageChannel } from "./permissions"; 5 6 export function registerCommands() { 7 // Link Slack channel to IRC channel ··· 60 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value; 61 // @ts-expect-error 62 const slackChannelId = payload.actions?.[0]?.value; 63 + const userId = payload.user?.id; 64 + 65 if (!context.respond) { 66 return; 67 } ··· 75 return; 76 } 77 78 + // Check if user has permission to manage this channel 79 + if (!userId || !(await canManageChannel(userId, slackChannelId))) { 80 + context.respond({ 81 + response_type: "ephemeral", 82 + text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.", 83 + replace_original: true, 84 + }); 85 + return; 86 + } 87 + 88 try { 89 channelMappings.create(slackChannelId, ircChannel); 90 ircClient.join(ircChannel); ··· 115 // Unlink Slack channel from IRC 116 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => { 117 const slackChannelId = payload.channel_id; 118 + const userId = payload.user_id; 119 const mapping = channelMappings.getBySlackChannel(slackChannelId); 120 121 if (!mapping) { ··· 126 return; 127 } 128 129 + // Check if user has permission to manage this channel 130 + if (!(await canManageChannel(userId, slackChannelId))) { 131 + context.respond({ 132 + response_type: "ephemeral", 133 + text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.", 134 + }); 135 + return; 136 + } 137 + 138 context.respond({ 139 response_type: "ephemeral", 140 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?", ··· 170 ], 171 }, 172 ], 173 }); 174 }); 175
+21 -10
src/index.ts
··· 19 function getAvatarForNick(nick: string): string { 20 let hash = 0; 21 for (let i = 0; i < nick.length; i++) { 22 - hash = ((hash << 5) - hash) + nick.charCodeAt(i); 23 hash = hash & hash; // Convert to 32bit integer 24 } 25 return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length]; ··· 126 127 // Parse IRC mentions and convert to Slack mentions 128 let messageText = parseIRCFormatting(text); 129 - 130 // Extract image URLs from the message 131 - const imagePattern = /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 132 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 133 - 134 // Find all @mentions and nick: mentions in the IRC message 135 const atMentionPattern = /@(\w+)/g; 136 const nickMentionPattern = /(\w+):/g; 137 - 138 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 139 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 140 - 141 for (const match of atMentions) { 142 const mentionedNick = match[1] as string; 143 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 144 if (mentionedUserMapping) { 145 - messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>`); 146 } 147 } 148 - 149 for (const match of nickMentions) { 150 const mentionedNick = match[1] as string; 151 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 152 if (mentionedUserMapping) { 153 - messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>:`); 154 } 155 } 156 ··· 238 try { 239 const response = await fetch( 240 `https://cachet.dunkirk.sh/users/${userId}`, 241 ); 242 if (response.ok) { 243 const data = (await response.json()) as CachetUser; ··· 275 276 if (response.ok) { 277 const data = await response.json(); 278 - 279 // Send each uploaded file URL to IRC 280 for (const file of data.files) { 281 const fileMessage = `<${username}> ${file.deployedUrl}`;
··· 19 function getAvatarForNick(nick: string): string { 20 let hash = 0; 21 for (let i = 0; i < nick.length; i++) { 22 + hash = (hash << 5) - hash + nick.charCodeAt(i); 23 hash = hash & hash; // Convert to 32bit integer 24 } 25 return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length]; ··· 126 127 // Parse IRC mentions and convert to Slack mentions 128 let messageText = parseIRCFormatting(text); 129 + 130 // Extract image URLs from the message 131 + const imagePattern = 132 + /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 133 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 134 + 135 // Find all @mentions and nick: mentions in the IRC message 136 const atMentionPattern = /@(\w+)/g; 137 const nickMentionPattern = /(\w+):/g; 138 + 139 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 140 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 141 + 142 for (const match of atMentions) { 143 const mentionedNick = match[1] as string; 144 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 145 if (mentionedUserMapping) { 146 + messageText = messageText.replace( 147 + match[0], 148 + `<@${mentionedUserMapping.slack_user_id}>`, 149 + ); 150 } 151 } 152 + 153 for (const match of nickMentions) { 154 const mentionedNick = match[1] as string; 155 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 156 if (mentionedUserMapping) { 157 + messageText = messageText.replace( 158 + match[0], 159 + `<@${mentionedUserMapping.slack_user_id}>:`, 160 + ); 161 } 162 } 163 ··· 245 try { 246 const response = await fetch( 247 `https://cachet.dunkirk.sh/users/${userId}`, 248 + { 249 + // @ts-ignore - Bun specific option 250 + tls: { rejectUnauthorized: false }, 251 + }, 252 ); 253 if (response.ok) { 254 const data = (await response.json()) as CachetUser; ··· 286 287 if (response.ok) { 288 const data = await response.json(); 289 + 290 // Send each uploaded file URL to IRC 291 for (const file of data.files) { 292 const fileMessage = `<${username}> ${file.deployedUrl}`;
+71
src/permissions.ts
···
··· 1 + /** 2 + * Check if a user has permission to manage a Slack channel 3 + * Returns true if the user is: 4 + * - A global admin (in ADMINS env var) 5 + * - The channel creator 6 + * - A channel manager 7 + */ 8 + export async function canManageChannel( 9 + userId: string, 10 + channelId: string, 11 + ): Promise<boolean> { 12 + // Check if user is a global admin 13 + const admins = process.env.ADMINS?.split(",").map((id) => id.trim()) || []; 14 + if (admins.includes(userId)) { 15 + return true; 16 + } 17 + 18 + try { 19 + // Check if user is channel creator 20 + const channelInfo = await fetch( 21 + "https://slack.com/api/conversations.info", 22 + { 23 + method: "POST", 24 + headers: { 25 + "Content-Type": "application/json", 26 + Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`, 27 + }, 28 + body: JSON.stringify({ channel: channelId }), 29 + }, 30 + ).then((res) => res.json()); 31 + 32 + if (channelInfo.ok && channelInfo.channel?.creator === userId) { 33 + return true; 34 + } 35 + 36 + // Check if user is a channel manager 37 + if ( 38 + process.env.SLACK_USER_COOKIE && 39 + process.env.SLACK_USER_TOKEN && 40 + process.env.SLACK_API_URL 41 + ) { 42 + const formdata = new FormData(); 43 + formdata.append("token", process.env.SLACK_USER_TOKEN); 44 + formdata.append("entity_id", channelId); 45 + 46 + const response = await fetch( 47 + `${process.env.SLACK_API_URL}/api/admin.roles.entity.listAssignments`, 48 + { 49 + method: "POST", 50 + headers: { 51 + Cookie: process.env.SLACK_USER_COOKIE, 52 + }, 53 + body: formdata, 54 + }, 55 + ); 56 + 57 + const json = await response.json(); 58 + 59 + if (json.ok) { 60 + const managers = json.role_assignments?.[0]?.users || []; 61 + if (managers.includes(userId)) { 62 + return true; 63 + } 64 + } 65 + } 66 + } catch (error) { 67 + console.error("Error checking channel permissions:", error); 68 + } 69 + 70 + return false; 71 + }