mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 16:18:17 +00:00
Fully switch to bun / remove slack / update package list
This commit is contained in:
parent
312d34d103
commit
3d86957e9f
11 changed files with 4 additions and 493 deletions
|
|
@ -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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,5 @@
|
|||
/splitfornpm/
|
||||
/.idea/
|
||||
/.env
|
||||
/bun.lockb
|
||||
/package-lock.json
|
||||
/.history
|
||||
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
29
index.js
29
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()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
const logger = require('../config/logger');
|
||||
|
||||
const getCdnUrl = () => process.env.AWS_CDN_URL;
|
||||
|
||||
const generateUrl = (version, fileName) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue