diff --git a/.env.example b/.env.example index 77c617d..08b5fc6 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,3 @@ -# Slack -SLACK_BOT_TOKEN=xoxb- # From OAuth & Permissions -SLACK_SIGNING_SECRET= # From Basic Information -SLACK_APP_TOKEN=xapp- # From Basic Information (for Socket Mode) -SLACK_CHANNEL_ID=channel-id # Channel where bot operates - # S3 Config CF in this example AWS_ACCESS_KEY_ID=1234567890abcdef AWS_SECRET_ACCESS_KEY=abcdef1234567890 diff --git a/.gitignore b/.gitignore index a3b3a54..adb25e2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,5 @@ /splitfornpm/ /.idea/ /.env -/bun.lockb /package-lock.json /.history \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..d91efc8 Binary files /dev/null and b/bun.lockb differ diff --git a/index.js b/index.js index 5893217..788abff 100644 --- a/index.js +++ b/index.js @@ -1,25 +1,11 @@ -const dotenv = require('dotenv'); -dotenv.config(); - const logger = require('./src/config/logger'); logger.info('Starting CDN application 🚀'); -// const {App} = require('@slack/bolt'); -const fileUpload = require('./src/fileUpload'); const express = require('express'); const cors = require('cors'); const apiRoutes = require('./src/api/index.js'); -const BOT_START_TIME = Date.now() / 1000; - -// const app = new App({ -// token: process.env.SLACK_BOT_TOKEN, -// signingSecret: process.env.SLACK_SIGNING_SECRET, -// socketMode: false, -// appToken: process.env.SLACK_APP_TOKEN -// }); - // API server const expressApp = express(); expressApp.use(cors()); @@ -51,27 +37,12 @@ expressApp.use((req, res, next) => { res.status(404).json({ error: 'Not found' }); }); -// Event listener for file_shared events -// app.event('file_shared', async ({event, client}) => { -// if (parseFloat(event.event_ts) < BOT_START_TIME) return; -// if (event.channel_id !== process.env.SLACK_CHANNEL_ID) return; - -// try { -// await fileUpload.handleFileUpload(event, client); -// } catch (error) { -// logger.error(`Upload failed: ${error.message}`); -// } -// }); - // Startup LOGs (async () => { try { - await fileUpload.initialize(); - // await app.start(); const port = parseInt(process.env.PORT || '4553', 10); expressApp.listen(port, () => { logger.info('CDN started successfully 🔥', { - slackMode: 'Socket Mode', apiPort: port, startTime: new Date().toISOString() }); diff --git a/logger.js b/logger.js deleted file mode 100644 index 2be320d..0000000 --- a/logger.js +++ /dev/null @@ -1,9 +0,0 @@ -const winston = require('winston'); - -const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine(winston.format.colorize(), winston.format.simple()), - transports: [new winston.transports.Console()], -}); - -module.exports = logger; \ No newline at end of file diff --git a/package.json b/package.json index 9003d87..aac87c0 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,15 @@ { "name": "cdn-v2-hackclub", "version": "1.0.0", - "description": "Slack app and API to upload files to S3-compatible storage with unique URLs", + "description": "API to upload files to S3-compatible storage with unique URLs", "main": "index.js", "scripts": { - "start": "node index.js" + "start": "bun index.js", + "dev": "bun --watch index.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.478.0", - "@slack/bolt": "^4.2.0", - "@slack/web-api": "^7.8.0", "cors": "^2.8.5", - "dotenv": "^10.0.0", "multer": "^1.4.5-lts.1", "node-fetch": "^2.6.1", "p-limit": "^6.2.0", diff --git a/src/api/upload.js b/src/api/upload.js index cd8c3df..03b3c13 100644 --- a/src/api/upload.js +++ b/src/api/upload.js @@ -1,7 +1,7 @@ const fetch = require('node-fetch'); const crypto = require('crypto'); const {uploadToStorage} = require('../storage'); -const {generateUrl, getCdnUrl} = require('./utils'); +const {generateUrl} = require('./utils'); const logger = require('../config/logger'); // Sanitize file name for storage diff --git a/src/api/utils.js b/src/api/utils.js index 4ce7c20..1dacc59 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -1,5 +1,3 @@ -const logger = require('../config/logger'); - const getCdnUrl = () => process.env.AWS_CDN_URL; const generateUrl = (version, fileName) => { diff --git a/src/config/messages.js b/src/config/messages.js deleted file mode 100644 index cb579e3..0000000 --- a/src/config/messages.js +++ /dev/null @@ -1,137 +0,0 @@ -const messages = { - success: { - singleFile: "Hey <@{userId}>, here's your link:", - multipleFiles: "Hey <@{userId}>, here are your links:", - alternateSuccess: [ - "thanks!", - "thanks, i'm gonna sell these to adfly!", - "tysm!", - "file away!" - ] - }, - fileTypes: { - gif: [ - "_gif_ that file to me and i'll upload it", - "_gif_ me all all your files!" - ], - heic: [ - "What the heic???" - ], - mov: [ - "I'll _mov_ that to a permanent link for you" - ], - html: [ - "Oh, launching a new website?", - "uwu, what's this site?", - "WooOOAAah hey! Are you serving a site?", - "h-t-m-ello :wave:" - ], - rar: [ - ".rawr xD", - "i also go \"rar\" sometimes!" - ] - }, - errors: { - tooBig: { - messages: [ - "File too big!", - "That's a chonky file!", - "_orpheus struggles to lift the massive file_", - "Sorry, that file's too thicc for me to handle!" - ], - images: [ - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/2too_big_4.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/3too_big_2.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/4too_big_1.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/6too_big_5.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/7too_big_3.png" - ] - }, - generic: { - messages: [ - "_orpheus sneezes and drops the files on the ground before blowing her nose on a blank jpeg._", - "_orpheus trips and your files slip out of her hands and into an inconveniently placed sewer grate._", - "_orpheus accidentally slips the files into a folder in her briefcase labeled \"homework\". she starts sweating profusely._" - ], - images: [ - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/0generic_3.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/1generic_2.png", - "https://cloud-3tq9t10za-hack-club-bot.vercel.app/5generic_1.png" - ] - } - } -}; - -function getRandomItem(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -function getFileTypeMessage(fileExtension) { - const ext = fileExtension.toLowerCase(); - return messages.fileTypes[ext] ? getRandomItem(messages.fileTypes[ext]) : null; -} - -function formatErrorMessage(failedFiles, isSizeError = false) { - const errorType = isSizeError ? messages.errors.tooBig : messages.errors.generic; - const errorMessage = getRandomItem(errorType.messages); - const errorImage = getRandomItem(errorType.images); - - return [ - errorMessage, - `Failed files: ${failedFiles.join(', ')}`, - '', - `<${errorImage}|image>` - ].join('\n'); -} - -function formatSuccessMessage(userId, files, failedFiles = [], sizeFailedFiles = []) { - const messageLines = []; - - const baseMessage = files.length === 1 ? - messages.success.singleFile : - messages.success.multipleFiles; - messageLines.push(baseMessage.replace('{userId}', userId), ''); - - const fileGroups = new Map(); - files.forEach(file => { - const ext = file.originalName.split('.').pop(); - const typeMessage = getFileTypeMessage(ext); - const key = typeMessage || 'noType'; - - if (!fileGroups.has(key)) { - fileGroups.set(key, []); - } - fileGroups.get(key).push(file); - }); - - fileGroups.forEach((groupFiles, typeMessage) => { - if (typeMessage !== 'noType') { - messageLines.push('', typeMessage); - } - - groupFiles.forEach(file => { - messageLines.push(`• ${file.originalName}: ${file.url}`); - }); - }); - - if (sizeFailedFiles.length > 0) { - messageLines.push(formatErrorMessage(sizeFailedFiles, true)); - } - if (failedFiles.length > 0) { - messageLines.push(formatErrorMessage(failedFiles, false)); - } - - if (files.length > 0) { - messageLines.push('', `_${getRandomItem(messages.success.alternateSuccess)}_`); - } - - return messageLines.join('\n'); -} - -module.exports = { - messages, - getFileTypeMessage, - formatSuccessMessage, - formatErrorMessage, - getRandomItem -}; diff --git a/src/fileUpload.js b/src/fileUpload.js deleted file mode 100644 index 3a3e616..0000000 --- a/src/fileUpload.js +++ /dev/null @@ -1,302 +0,0 @@ -const fetch = require('node-fetch'); -const crypto = require('crypto'); -const logger = require('./config/logger'); -const storage = require('./storage'); -const {generateFileUrl} = require('./utils'); -const path = require('path'); -const { - messages, - formatSuccessMessage, - formatErrorMessage, - getFileTypeMessage -} = require('./config/messages'); - -const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB in bytes -const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) - -const processedMessages = new Map(); -let uploadLimit; - -async function initialize() { - const pLimit = (await import('p-limit')).default; - uploadLimit = pLimit(CONCURRENT_UPLOADS); -} - -// Basic stuff -function isMessageTooOld(eventTs) { - const eventTime = parseFloat(eventTs) * 1000; - return (Date.now() - eventTime) > 24 * 60 * 60 * 1000; -} - -function isMessageProcessed(messageTs) { - return processedMessages.has(messageTs); -} - -function markMessageAsProcessing(messageTs) { - processedMessages.set(messageTs, true); -} - -// File processing -function sanitizeFileName(fileName) { - let sanitized = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - return sanitized || `upload_${Date.now()}`; -} - -function generateUniqueFileName(fileName) { - return `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizeFileName(fileName)}`; -} - -// upload functionality -async function processFiles(fileMessage, client) { - const uploadedFiles = []; - const failedFiles = []; - const sizeFailedFiles = []; - const fileTypeResponses = new Set(); - - logger.info(`Processing ${fileMessage.files?.length || 0} files`); - - for (const file of fileMessage.files || []) { - try { - if (file.size > MAX_FILE_SIZE) { - sizeFailedFiles.push(file.name); - continue; - } - - // Get file extension message if applicable - const ext = path.extname(file.name).slice(1); - const typeMessage = getFileTypeMessage(ext); - if (typeMessage) fileTypeResponses.add(typeMessage); - - const response = await fetch(file.url_private, { - headers: {Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`} - }); - - if (!response.ok) throw new Error('Download failed'); - - const buffer = await response.buffer(); - const uniqueFileName = generateUniqueFileName(file.name); - const userDir = `s/${fileMessage.user}`; - - const success = await uploadLimit(() => - storage.uploadToStorage(userDir, uniqueFileName, buffer, file.mimetype) - ); - - if (!success) throw new Error('Upload failed'); - - uploadedFiles.push({ - name: uniqueFileName, - originalName: file.name, - url: generateFileUrl(userDir, uniqueFileName), - contentType: file.mimetype - }); - - } catch (error) { - logger.error(`Failed: ${file.name} - ${error.message}`); - failedFiles.push(file.name); - } - } - - return { - uploadedFiles, - failedFiles, - sizeFailedFiles, - isSizeError: sizeFailedFiles.length > 0 - }; -} - -// Slack interaction -async function addProcessingReaction(client, event, fileMessage) { - try { - await client.reactions.add({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: event.channel_id - }); - } catch (error) { - logger.error('Failed to add reaction:', error.message); - } -} - -async function updateReactions(client, event, fileMessage, totalFiles, failedCount) { - try { - await client.reactions.remove({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: event.channel_id - }); - - // Choose reaction based on how many files failed or well succeded - let reactionName; - if (failedCount === totalFiles) { - reactionName = 'x'; // All files failed - } else if (failedCount > 0) { - reactionName = 'warning'; // Some files failed - } else { - reactionName = 'white_check_mark'; // All files succeeded - } - - await client.reactions.add({ - name: reactionName, - timestamp: fileMessage.ts, - channel: event.channel_id - }); - } catch (error) { - logger.error('Failed to update reactions:', error.message); - } -} - -async function findFileMessage(event, client) { - try { - const fileInfo = await client.files.info({ - file: event.file_id, - include_shares: true - }); - - if (!fileInfo.ok || !fileInfo.file) { - throw new Error('Could not get file info'); - } - - const channelShare = fileInfo.file.shares?.public?.[event.channel_id] || - fileInfo.file.shares?.private?.[event.channel_id]; - - if (!channelShare || !channelShare.length) { - throw new Error('No share info found for this channel'); - } - - // Get the EXACT message using the ts from share info (channelShare) - const messageTs = channelShare[0].ts; - - const messageInfo = await client.conversations.history({ - channel: event.channel_id, - latest: messageTs, - limit: 1, - inclusive: true - }); - - if (!messageInfo.ok || !messageInfo.messages.length) { - throw new Error('Could not find original message'); - } - - return messageInfo.messages[0]; - } catch (error) { - logger.error('Error finding file message:', error); - return null; - } -} - -async function sendResultsMessage(client, channelId, fileMessage, uploadedFiles, failedFiles, sizeFailedFiles) { - try { - let message; - if (uploadedFiles.length === 0 && (failedFiles.length > 0 || sizeFailedFiles.length > 0)) { - // All files failed - use appropriate error type - message = formatErrorMessage( - [...failedFiles, ...sizeFailedFiles], - sizeFailedFiles.length > 0 && failedFiles.length === 0 // Only use size error if all failures are size-related (i hope this is how it makes most sense) - ); - } else { - // Mixed success/failure or all success - message = formatSuccessMessage( - fileMessage.user, - uploadedFiles, - failedFiles, - sizeFailedFiles - ); - } - - const lines = message.split('\n'); - const attachments = []; - let textBuffer = ''; - - for (const line of lines) { - if (line.match(/^<.*\|image>$/)) { - const imageUrl = line.replace(/^<|>$/g, '').replace('|image', ''); - attachments.push({ - image_url: imageUrl, - fallback: 'Error image' - }); - } else { - textBuffer += line + '\n'; - } - } - - await client.chat.postMessage({ - channel: channelId, - thread_ts: fileMessage.ts, - text: textBuffer.trim(), - attachments: attachments.length > 0 ? attachments : undefined - }); - } catch (error) { - logger.error('Failed to send results message:', error); - throw error; - } -} - -async function handleError(client, channelId, fileMessage, reactionAdded) { - if (fileMessage && reactionAdded) { - try { - await client.reactions.remove({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: channelId - }); - } catch (cleanupError) { - if (cleanupError.data.error !== 'no_reaction') { - logger.error('Cleanup error:', cleanupError); - } - } - try { - await client.reactions.add({ - name: 'x', - timestamp: fileMessage.ts, - channel: channelId - }); - } catch (cleanupError) { - logger.error('Cleanup error:', cleanupError); - } - } -} - -async function handleFileUpload(event, client) { - let fileMessage = null; - let reactionAdded = false; - - try { - if (isMessageTooOld(event.event_ts)) return; - - fileMessage = await findFileMessage(event, client); - if (!fileMessage || isMessageProcessed(fileMessage.ts)) return; - - markMessageAsProcessing(fileMessage.ts); - await addProcessingReaction(client, event, fileMessage); - reactionAdded = true; - - const {uploadedFiles, failedFiles, sizeFailedFiles} = await processFiles(fileMessage, client); - - const totalFiles = uploadedFiles.length + failedFiles.length + sizeFailedFiles.length; - const failedCount = failedFiles.length + sizeFailedFiles.length; - - await sendResultsMessage( - client, - event.channel_id, - fileMessage, - uploadedFiles, - failedFiles, - sizeFailedFiles - ); - - await updateReactions( - client, - event, - fileMessage, - totalFiles, - failedCount - ); - - } catch (error) { - logger.error(`Upload failed: ${error.message}`); - await handleError(client, event.channel_id, fileMessage, reactionAdded); - throw error; - } -} - -module.exports = { handleFileUpload, initialize }; diff --git a/src/storage.js b/src/storage.js index 0968212..655f76e 100644 --- a/src/storage.js +++ b/src/storage.js @@ -1,5 +1,4 @@ const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); -const path = require('path'); const crypto = require('crypto'); const logger = require('./config/logger'); const {generateFileUrl} = require('./utils');