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 2 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 3 3 SLACK_SIGNING_SECRET=your-signing-secret-here 4 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 + 5 12 # IRC Configuration 6 13 IRC_NICK=slackbridge 7 14 8 15 # Admin users (comma-separated Slack user IDs) 9 - ADMINS=U1234567890 16 + ADMINS=U1234567890,U0987654321 10 17 11 18 # Hack Club CDN Token (for file uploads) 12 19 CDN_TOKEN=your-cdn-token-here
+11 -1
README.md
··· 31 31 SLACK_BOT_TOKEN=xoxb-your-bot-token-here 32 32 SLACK_SIGNING_SECRET=your-signing-secret-here 33 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 + 34 41 # IRC Configuration 35 42 IRC_NICK=slackbridge 36 43 37 44 # Admin users (comma-separated Slack user IDs) 38 - ADMINS=U1234567890 45 + ADMINS=U1234567890,U0987654321 39 46 40 47 # Hack Club CDN Token (for file uploads) 41 48 CDN_TOKEN=your-cdn-token-here 42 49 43 50 # Server Configuration (optional) 44 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 45 55 ``` 46 56 47 57 See `.env.example` for a template.
+23 -1
src/commands.ts
··· 1 1 import type { AnyMessageBlock, Block, BlockElement } from "slack-edge"; 2 2 import { channelMappings, userMappings } from "./db"; 3 3 import { slackApp, ircClient } from "./index"; 4 + import { canManageChannel } from "./permissions"; 4 5 5 6 export function registerCommands() { 6 7 // Link Slack channel to IRC channel ··· 59 60 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value; 60 61 // @ts-expect-error 61 62 const slackChannelId = payload.actions?.[0]?.value; 63 + const userId = payload.user?.id; 64 + 62 65 if (!context.respond) { 63 66 return; 64 67 } ··· 72 75 return; 73 76 } 74 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 + 75 88 try { 76 89 channelMappings.create(slackChannelId, ircChannel); 77 90 ircClient.join(ircChannel); ··· 102 115 // Unlink Slack channel from IRC 103 116 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => { 104 117 const slackChannelId = payload.channel_id; 118 + const userId = payload.user_id; 105 119 const mapping = channelMappings.getBySlackChannel(slackChannelId); 106 120 107 121 if (!mapping) { ··· 112 126 return; 113 127 } 114 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 + 115 138 context.respond({ 116 139 response_type: "ephemeral", 117 140 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?", ··· 147 170 ], 148 171 }, 149 172 ], 150 - replace_original: true, 151 173 }); 152 174 }); 153 175
+21 -10
src/index.ts
··· 19 19 function getAvatarForNick(nick: string): string { 20 20 let hash = 0; 21 21 for (let i = 0; i < nick.length; i++) { 22 - hash = ((hash << 5) - hash) + nick.charCodeAt(i); 22 + hash = (hash << 5) - hash + nick.charCodeAt(i); 23 23 hash = hash & hash; // Convert to 32bit integer 24 24 } 25 25 return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length]; ··· 126 126 127 127 // Parse IRC mentions and convert to Slack mentions 128 128 let messageText = parseIRCFormatting(text); 129 - 129 + 130 130 // Extract image URLs from the message 131 - const imagePattern = /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 131 + const imagePattern = 132 + /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 132 133 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 133 - 134 + 134 135 // Find all @mentions and nick: mentions in the IRC message 135 136 const atMentionPattern = /@(\w+)/g; 136 137 const nickMentionPattern = /(\w+):/g; 137 - 138 + 138 139 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 139 140 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 140 - 141 + 141 142 for (const match of atMentions) { 142 143 const mentionedNick = match[1] as string; 143 144 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 144 145 if (mentionedUserMapping) { 145 - messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>`); 146 + messageText = messageText.replace( 147 + match[0], 148 + `<@${mentionedUserMapping.slack_user_id}>`, 149 + ); 146 150 } 147 151 } 148 - 152 + 149 153 for (const match of nickMentions) { 150 154 const mentionedNick = match[1] as string; 151 155 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 152 156 if (mentionedUserMapping) { 153 - messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>:`); 157 + messageText = messageText.replace( 158 + match[0], 159 + `<@${mentionedUserMapping.slack_user_id}>:`, 160 + ); 154 161 } 155 162 } 156 163 ··· 238 245 try { 239 246 const response = await fetch( 240 247 `https://cachet.dunkirk.sh/users/${userId}`, 248 + { 249 + // @ts-ignore - Bun specific option 250 + tls: { rejectUnauthorized: false }, 251 + }, 241 252 ); 242 253 if (response.ok) { 243 254 const data = (await response.json()) as CachetUser; ··· 275 286 276 287 if (response.ok) { 277 288 const data = await response.json(); 278 - 289 + 279 290 // Send each uploaded file URL to IRC 280 291 for (const file of data.files) { 281 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 + }