From 09441c495b073e9bf27b2d4a5b247b03d93e8a58 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Sat, 18 Jan 2025 03:49:27 +0100 Subject: [PATCH 01/11] =?UTF-8?q?Codebase=20Rewrite=20=E2=80=93=20Slack=20?= =?UTF-8?q?Bot,=20Backblaze=20B2=20Migration,=20API=20v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update is a full rewrite of the codebase with major improvements 💪 : - Slack Bot Integration – Added now built-in Slack bot! - Backblaze B2 Migration – Switched from Vercel to B2, cutting storage / egress costs by around 90%. - API v3 – New version includes file hashes, sizes, and additional metadata. - API Token Requirement – ⚠️ All older API versions (v1, v2) now require authentication tokens. ⚠️ -- Deployor 💜 --- .gitignore | 8 +- README.md | 285 ++++++++++++++++++++++++++++++++++++------- api/v1/new.ts | 128 ------------------- api/v1/newSingle.ts | 64 ---------- api/v2/deploy.ts | 40 ------ api/v2/new.ts | 37 ------ api/v2/upload.ts | 53 -------- api/v2/utils.ts | 57 --------- index.js | 88 +++++++++++++ logger.js | 9 ++ package.json | 22 ++++ src/api.js | 17 +++ src/api/deploy.js | 26 ++++ src/api/index.js | 85 +++++++++++++ src/api/upload.js | 56 +++++++++ src/api/utils.js | 45 +++++++ src/backblaze.js | 32 +++++ src/config/logger.js | 23 ++++ src/fileUpload.js | 230 ++++++++++++++++++++++++++++++++++ src/upload.js | 32 +++++ src/utils.js | 8 ++ vercel.json | 11 -- 22 files changed, 919 insertions(+), 437 deletions(-) delete mode 100644 api/v1/new.ts delete mode 100644 api/v1/newSingle.ts delete mode 100644 api/v2/deploy.ts delete mode 100644 api/v2/new.ts delete mode 100644 api/v2/upload.ts delete mode 100644 api/v2/utils.ts create mode 100644 index.js create mode 100644 logger.js create mode 100644 package.json create mode 100644 src/api.js create mode 100644 src/api/deploy.js create mode 100644 src/api/index.js create mode 100644 src/api/upload.js create mode 100644 src/api/utils.js create mode 100644 src/backblaze.js create mode 100644 src/config/logger.js create mode 100644 src/fileUpload.js create mode 100644 src/upload.js create mode 100644 src/utils.js delete mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index edaaef5..01d6024 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -.env -.vercel -.vscode +/node_modules/ +/splitfornpm/ +/.idea/ +/.env +/package-lock.json diff --git a/README.md b/README.md index f01d49a..3a883d1 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,241 @@ -

CDN

-

Deep under the waves and storms there lies a vault...

-

Raft icon

-

Illustration above by @maxwofford.

- ---- - -CDN powers the [#cdn](https://app.slack.com/client/T0266FRGM/C016DEDUL87) channel in the [Hack Club Slack](https://hackclub.com/slack). - -## Version 2 Version 2 - -Post this JSON... -```js -[ - "website.com/somefile.png", - "website.com/somefile.gif", -] -``` - -And it'll return the following: -```js -{ - "0somefile.png": "cdnlink.vercel.app/0somefile.png", - "1somefile.gif": "cdnlink.vercel.app/1somefile.gif" -} -``` - -## Version 1 Version 1 - -Post this JSON... -```js -[ - "website.com/somefile.png", - "website.com/somefile.gif", -] -``` - -And it'll return the following: -```js -[ - "cdnlink.vercel.app/0somefile.png", - "cdnlink.vercel.app/1somefile.gif" -] -``` +
+ +

CDN

+

A CDN solution for Hack Club!

+
+ +

Deep under the waves and storms there lies a vault...

+ +
+ +

Banner illustration by @maxwofford.

+ + + Slack Channel + +
+ +## 🚀 Features + +- **Multi-version API Support** (v1, v2, v3) +- **Slack Bot Integration** + - Upload up to 10 files per message + - Automatic file sanitization + - file organization +- **Secure API Endpoints** +- **Cost-Effective Storage** (87-98% cost reduction vs. Vercel CDN) +- **Prevent File Deduplication** +- **Organized Storage Structure** + +## 🔧 Setup + +### 1. Slack App Configuration + +1. Create a new Slack App at [api.slack.com](https://api.slack.com/apps) +2. Enable Socket Mode in the app settings +3. Add the following Bot Token Scopes: + - `channels:history` + - `channels:read` + - `chat:write` + - `files:read` + - `files:write` + - `groups:history` + - `reactions:write` +4. Enable Event Subscriptions and subscribe to `file_shared` event +5. Install the app to your workspace + +### 2. CDN Configuration (Cloudflare + Backblaze) + +1. Create a Backblaze B2 bucket +2. Set up Cloudflare DNS: + - Add a CNAME record pointing to your B2 bucket (e.g., `f003.backblazeb2.com`) you can upload a file and check in info! + - Enable Cloudflare proxy +3. Configure SSL/TLS: + - Set SSL mode to "Full (strict)" + - ⚠️ **WARNING**: This setting may break other configurations on your domain! You could use another domain! +4. Create a Transform Rule: + - Filter: `hostname equals "your-cdn.example.com"` + - Rewrite to: `concat("/file/(bucket name)", http.request.uri.path)` (make sure u get the bucket name) + - Preserve query string + +### 3. Environment Setup + +Create a `.env` file with: +```env +# 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 + +# Backblaze (Public Bucket) +B2_APP_KEY_ID=key-id # From B2 Application Keys +B2_APP_KEY=app-key # From B2 Application Keys +B2_BUCKET_ID=bucket-id # From B2 Bucket Settings +B2_CDN_URL=https://cdn.example.com + +# API +API_TOKEN=beans # Set a secure random string +PORT=3000 +``` + +### 4. Installation & Running + +```bash +npm install +node index.js +``` +Feel free to use pm2! + +## 📡 API Usage + +⚠️ **IMPORTANT SECURITY NOTE**: +- All API endpoints require authentication via `Authorization: Bearer api-token` header +- This includes all versions (v1, v2, v3) - no exceptions! +- Use the API_TOKEN from your environment configuration +- Failure to include a valid token will result in 401 Unauthorized responses + +### V3 API (Latest) +Version 3 + +**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v3/new` + +**Headers:** +``` +Authorization: Bearer api-token +Content-Type: application/json +``` + +**Request Example:** +```bash +curl --location 'https://e2.deployor.hackclub.app/api/v3/new' \ +--header 'Authorization: Bearer beans' \ +--header 'Content-Type: application/json' \ +--data '[ + "https://assets.hackclub.com/flag-standalone.svg", + "https://assets.hackclub.com/flag-orpheus-left.png", + "https://assets.hackclub.com/icon-progress-marker.svg" +]' +``` + +**Response:** +```json +{ + "files": [ + { + "deployedUrl": "https://cdn.deployor.dev/s/v3/3e48b91a4599a3841c028e9a683ef5ce58cea372_flag-standalone.svg", + "file": "0_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095691", + "sha": "16361167e11b0d172a47e726b40d70e9873c792b", + "size": 90173 + } + // Other files + ], + "cdnBase": "https://cdn.deployor.dev" +} +``` + +
+V2 API + +Version 2 + +**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v2/new` + +**Headers:** +``` +Authorization: Bearer api-token +Content-Type: application/json +``` + +**Request Example:** +```json +[ + "https://assets.hackclub.com/flag-standalone.svg", + "https://assets.hackclub.com/flag-orpheus-left.png", + "https://assets.hackclub.com/icon-progress-marker.svg" +] +``` + +**Response:** +```json +{ + "flag-standalone.svg": "https://cdn.deployor.dev/s/v2/flag-standalone.svg", + "flag-orpheus-left.png": "https://cdn.deployor.dev/s/v2/flag-orpheus-left.png", + "icon-progress-marker.svg": "https://cdn.deployor.dev/s/v2/icon-progress-marker.svg" +} +``` +
+ +
+V1 API + +Version 1 + +**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v1/new` + +**Headers:** +``` +Authorization: Bearer api-token +Content-Type: application/json +``` + +**Request Example:** +```json +[ + "https://assets.hackclub.com/flag-standalone.svg", + "https://assets.hackclub.com/flag-orpheus-left.png", + "https://assets.hackclub.com/icon-progress-marker.svg" +] +``` + +**Response:** +```json +[ + "https://cdn.deployor.dev/s/v1/0_flag-standalone.svg", + "https://cdn.deployor.dev/s/v1/1_flag-orpheus-left.png", + "https://cdn.deployor.dev/s/v1/2_icon-progress-marker.svg" +] +``` +
+ +## 🤖 Slack Bot Features + +- **Multi-file Upload:** Upload up to 10 files in a single message no more than 3 messages at a time! +- **File Organization:** Files are stored as `/s/{slackUserId}/{timestamp}_{sanitizedFilename}` +- **Error Handling:** Error Handeling +- **File Sanitization:** Automatic filename cleaning +- **Size Limits:** Enforces files to be under 2GB + +## Legacy API Notes +- V1 and V2 APIs are maintained for backwards compatibility +- All versions now require authentication via Bearer token +- We recommend using V3 API for new implementations + +## Technical Details + +- **Storage Structure:** `/s/v3/{HASH}_{filename}` +- **File Naming:** `/s/{slackUserId}/{unix}_{sanitizedFilename}` +- **Cost Efficiency:** Uses B2 storage for significant cost savings +- **Security:** Token-based authentication for API access + +## 💻 Slack Bot Behavior + +- Reacts to file uploads with status emojis: + - ⏳ Processing + - ✅ Success + - ❌ Error +- Supports up to 10 files per message +- Max 3 messages concurrently! +- Maximum file size: 2GB per file + +## 💰 Cost Optimization + +- Uses Cloudflare CDN with Backblaze B2 storage +- Free egress thanks to Cloudflare-Backblaze Alliance +- 87-98% cost reduction compared to Vercel CDN + +
+
+

Made with 💜 for Hack Club

+

All illustrations by @maxwofford

+
\ No newline at end of file diff --git a/api/v1/new.ts b/api/v1/new.ts deleted file mode 100644 index 4a47873..0000000 --- a/api/v1/new.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { urlParse } from "https://deno.land/x/url_parse/mod.ts"; - -const endpoint = (path: string) => { - // https://vercel.com/docs/api#api-basics/authentication/accessing-resources-owned-by-a-team - let url = "https://api.vercel.com/" + path; - if (Deno.env.get("ZEIT_TEAM")) { - url += ("?teamId=" + Deno.env.get("ZEIT_TEAM")); - } - return url; -}; - -const deploy = async ( - files: { sha: string; file: string; path: string; size: number }[], -) => { - const req = await fetch(endpoint("v12/now/deployments"), { - method: "POST", - headers: { - "Authorization": `Bearer ${Deno.env.get("ZEIT_TOKEN")}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "cloud", - files: files.map((f) => ({ - sha: f.sha, - file: f.file, - size: f.size, - })), - projectSettings: { - framework: null, - }, - }), - }); - const json = await req.text(); - console.log(json) - const baseURL = JSON.parse(json).url; - const fileURLs = files.map((f) => "https://" + baseURL + "/" + f.path); - - return { status: req.status, fileURLs }; -}; - -export default async (req: Request) => { - if (req.method == "OPTIONS") { - return new Response( - JSON.stringify( - { status: "YIPPE YAY. YOU HAVE CLEARANCE TO PROCEED." }, - ), - { - status: 204 - }, - ); - } - if (req.method == "GET") { - return new Response( - JSON.stringify( - { error: "*GET outta here!* (Method not allowed, use POST)" }, - ), - { - status: 405 - }, - ); - } - if (req.method == "PUT") { - return new Response( - JSON.stringify( - { error: "*PUT that request away!* (Method not allowed, use POST)" }, - ), - { - status: 405, - }, - ); - } - if (req.method != "POST") { - return new Response( - JSON.stringify({ error: "Method not allowed, use POST" }), - { - status: 405, - }, - ); - } - - const decoder = new TextDecoder(); - console.log(req) - const buf = await req.arrayBuffer(); - console.log(decoder.decode(buf)) - console.log(buf) - const fileURLs = JSON.parse(decoder.decode(buf)); - console.log(fileURLs) - console.log(fileURLs.length) - console.log(typeof fileURLs) - if (!Array.isArray(fileURLs) || fileURLs.length < 1) { - return new Response( - JSON.stringify({ error: "Empty file array" }), - { status: 422 } - ); - } - - const authorization = req.headers.get("Authorization"); - - const uploadedURLs = await Promise.all(fileURLs.map(async (url, index) => { - const { pathname } = urlParse(url); - const filename = index + pathname.substr(pathname.lastIndexOf("/") + 1); - - const headers = { - "Content-Type": "application/json", - "Authorization": "" - } - if (authorization) { - headers['Authorization'] = authorization; - } - const res = await (await fetch("https://cdn.hackclub.com/api/newSingle", { - method: "POST", - headers, - body: url, - })).json(); - - res.file = "public/" + filename; - res.path = filename; - - return res; - })); - - const result = await deploy(uploadedURLs); - - return new Response( - JSON.stringify(result.fileURLs), - { status: result.status } - ); -}; diff --git a/api/v1/newSingle.ts b/api/v1/newSingle.ts deleted file mode 100644 index f3de9e9..0000000 --- a/api/v1/newSingle.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Hash } from "https://deno.land/x/checksum@1.4.0/mod.ts"; - -const endpoint = (path: string) => { - // https://vercel.com/docs/api#api-basics/authentication/accessing-resources-owned-by-a-team - let url = "https://api.vercel.com/" + path; - if (Deno.env.get("ZEIT_TEAM")) { - url += ("?teamId=" + Deno.env.get("ZEIT_TEAM")); - } - return url; -}; - -const uploadFile = async (url: string, authorization: string|null) => { - const options = { - method: 'GET', headers: { 'Authorization': "" } - } - if (authorization) { - options.headers = { 'Authorization': authorization } - } - const req = await fetch(url, options); - const data = new Uint8Array(await req.arrayBuffer()); - const sha = new Hash("sha1").digest(data).hex(); - const size = data.byteLength; - - await fetch(endpoint("v2/now/files"), { - method: "POST", - headers: { - "Content-Length": size.toString(), - "x-now-digest": sha, - "Authorization": `Bearer ${Deno.env.get("ZEIT_TOKEN")}`, - }, - body: data.buffer, - }); - - return { - sha, - size, - }; -}; - -export default async (req: Request) => { - if (req.method != "POST") { - return new Response( - JSON.stringify({ error: "Method not allowed, use POST" }), - { - status: 405, - }, - ); - } - - const decoder = new TextDecoder(); - const buf = await req.arrayBuffer(); - const singleFileURL = decoder.decode(buf); - if (typeof singleFileURL != "string") { - return new Response( - JSON.stringify({ error: "newSingle only accepts a single URL" }), - { - status: 422 - }, - ); - } - const uploadedFileURL = await uploadFile(singleFileURL, req.headers.get("Authorization")); - - return new Response(JSON.stringify(uploadedFileURL)) -}; diff --git a/api/v2/deploy.ts b/api/v2/deploy.ts deleted file mode 100644 index aa083c4..0000000 --- a/api/v2/deploy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { endpoint } from "./utils.ts"; - -// Other functions can import this function to call this serverless endpoint -export const deployEndpoint = async ( - files: { sha: string; file: string; size: number }[], -) => { - return await deploy(files); -}; - -const deploy = async ( - files: { sha: string; file: string; size: number }[], -) => { - const req = await fetch(endpoint("v12/now/deployments"), { - method: "POST", - headers: { - "Authorization": `Bearer ${Deno.env.get("ZEIT_TOKEN")}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "cloud", - files: files.map((f) => ({ - sha: f.sha, - file: f.file, - size: f.size, - })), - projectSettings: { - framework: null, - }, - }), - }); - const json = await req.json(); - const deployedFiles = files.map((file) => ({ - deployedUrl: `https://${json.url}/public/${file.file}`, - ...file, - })); - - return { status: req.status, files: deployedFiles }; -}; - -export default deploy; diff --git a/api/v2/new.ts b/api/v2/new.ts deleted file mode 100644 index 1c27611..0000000 --- a/api/v2/new.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { urlParse } from "https://deno.land/x/url_parse/mod.ts"; -import { uploadEndpoint } from "./upload.ts"; -import { deployEndpoint } from "./deploy.ts"; -import { ensurePost, parseBody } from "./utils.ts"; - -export default async (req: Request) => { - if (!ensurePost(req)) return null; - - const body = new TextDecoder().decode(await req.arrayBuffer()); - const fileURLs = JSON.parse(body); - - if (!Array.isArray(fileURLs) || fileURLs.length < 1) { - return new Response( - JSON.stringify({ error: "Empty/invalid file array" }), - { status: 422 } - ); - } - - const authorization = req.headers.get('Authorization') - - const uploadArray = await Promise.all(fileURLs.map(f => uploadEndpoint(f, authorization))); - - const deploymentFiles = uploadArray.map( - (file: { url: string; sha: string; size: number }, index: number) => { - const { pathname } = urlParse(file.url); - const filename = index + pathname.substr(pathname.lastIndexOf("/") + 1); - return { sha: file.sha, file: filename, size: file.size }; - }, - ); - - const deploymentData = await deployEndpoint(deploymentFiles); - - return new Response( - JSON.stringify(deploymentData.files), - { status: deploymentData.status } - ); -}; diff --git a/api/v2/upload.ts b/api/v2/upload.ts deleted file mode 100644 index 263f047..0000000 --- a/api/v2/upload.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Hash } from "https://deno.land/x/checksum@1.4.0/hash.ts"; -import { endpoint, ensurePost, parseBody } from "./utils.ts"; - -// Other functions can import this function to call this serverless endpoint -export const uploadEndpoint = async (url: string, authorization: string | null) => { - const options = { method: 'POST', body: url, headers: {} } - if (authorization) { - options.headers = { 'Authorization': authorization } - } - console.log({ options}) - const response = await fetch("https://cdn.hackclub.com/api/v2/upload", options); - const result = await response.json(); - console.log({result}) - - return result; -}; - -const upload = async (url: string, authorization: string | null) => { - const options = { headers: {} } - if (authorization) { - options.headers = { 'Authorization': authorization } - } - const req = await fetch(url, options); - const reqArrayBuffer = await req.arrayBuffer(); - const data = new Uint8Array(reqArrayBuffer); - const sha = new Hash("sha1").digest(data).hex(); - const size = data.byteLength; - - await fetch(endpoint("v2/now/files"), { - method: "POST", - headers: { - "Content-Length": size.toString(), - "x-now-digest": sha, - "Authorization": `Bearer ${Deno.env.get("ZEIT_TOKEN")}`, - }, - body: data.buffer, - }); - - return { - url, - sha, - size, - }; -}; - -export default async (req: Request) => { - if (!ensurePost(req)) return null; - - const body = new TextDecoder().decode(await req.arrayBuffer()); - const uploadedFileUrl = await upload(body, req.headers.get("Authorization")); - - return new Response(JSON.stringify(uploadedFileUrl)); -}; diff --git a/api/v2/utils.ts b/api/v2/utils.ts deleted file mode 100644 index aa305e8..0000000 --- a/api/v2/utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const endpoint = (path: string) => { - // https://vercel.com/docs/api#api-basics/authentication/accessing-resources-owned-by-a-team - let url = "https://api.vercel.com/" + path; - if (Deno.env.get("ZEIT_TEAM")) { - url += ("?teamId=" + Deno.env.get("ZEIT_TEAM")); - } - return url; -}; - -export const parseBody = async (body: Request["body"]) => { - const decoder = new TextDecoder(); - const buf = await Deno.readAll(body); - const result = decoder.decode(buf); - return result; -}; - -export const ensurePost = (req: Request) => { - if (req.method == "OPTIONS") { - return new Response( - JSON.stringify( - { status: "YIPPE YAY. YOU HAVE CLEARANCE TO PROCEED." }, - ), - { - status: 204 - }, - ); - } - if (req.method == "GET") { - return new Response( - JSON.stringify( - { error: "*GET outta here!* (Method not allowed, use POST)" }, - ), - { - status: 405 - }, - ); - } - if (req.method == "PUT") { - return new Response( - JSON.stringify( - { error: "*PUT that request away!* (Method not allowed, use POST)" }, - ), - { - status: 405, - }, - ); - } - if (req.method != "POST") { - return new Response( - JSON.stringify({ error: "Method not allowed, use POST" }), - { - status: 405, - }, - ); - } - return true; -}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..267b2ee --- /dev/null +++ b/index.js @@ -0,0 +1,88 @@ +const dotenv = require('dotenv'); +dotenv.config(); + +const logger = require('./src/config/logger'); +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: true, + appToken: process.env.SLACK_APP_TOKEN +}); + +// API server +const expressApp = express(); +expressApp.use(cors()); +expressApp.use(express.json()); +expressApp.use(express.urlencoded({ extended: true })); + +// Log ALL incoming requests for debugging +expressApp.use((req, res, next) => { + logger.info(`Incoming request: ${req.method} ${req.path}`); + next(); +}); + +// Log statement before mounting the API routes +logger.info('Mounting API routes'); + +// Mount API for all versions +expressApp.use('/api', apiRoutes); + +// Error handling middleware +expressApp.use((err, req, res, next) => { + logger.error('API Error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Fallback route for unhandled paths +expressApp.use((req, res, next) => { + logger.warn(`Unhandled route: ${req.method} ${req.path}`); + res.status(404).json({ error: 'Not found' }); +}); + +// Event listener for file_shared events +app.event('file_shared', async ({event, client}) => { + logger.debug(`Received file_shared event: ${JSON.stringify(event)}`); + + if (parseFloat(event.event_ts) < BOT_START_TIME) { + logger.info(`Ignoring file event from before bot start: ${new Date(parseFloat(event.event_ts) * 1000).toISOString()}`); + return; + } + + const targetChannelId = process.env.SLACK_CHANNEL_ID; + const channelId = event.channel_id; + + if (channelId !== targetChannelId) { + logger.info(`Ignoring file shared in channel: ${channelId}`); + return; + } + + try { + await fileUpload.handleFileUpload(event, client); + } catch (error) { + logger.error(`Error processing file upload: ${error.message}`); + } +}); + +// Slack bot and API server +(async () => { + try { + await fileUpload.initialize(); + await app.start(); + const port = parseInt(process.env.API_PORT || '4553', 10); + expressApp.listen(port, () => { + logger.info(`⚡️ Slack app is running in Socket Mode!`); + logger.info(`🚀 API server is running on port ${port}`); + }); + } catch (error) { + logger.error('Failed to start:', error); + process.exit(1); + } +})(); diff --git a/logger.js b/logger.js new file mode 100644 index 0000000..2be320d --- /dev/null +++ b/logger.js @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..a571cb5 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "cdn-v2-hackclub", + "version": "1.0.0", + "description": "Slack app to upload files to Backblaze B2 with unique URLs", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@slack/bolt": "^4.2.0", + "@slack/web-api": "^7.8.0", + "backblaze-b2": "^1.3.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", + "winston": "^3.17.0" + }, + "author": "deployor", + "license": "MIT" +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..bfc5247 --- /dev/null +++ b/src/api.js @@ -0,0 +1,17 @@ +const express = require('express'); +const multer = require('multer'); +const router = express.Router(); +const upload = multer({dest: 'uploads/'}); + +router.post('/upload', upload.single('file'), (req, res) => { + if (!req.file) { + return res.status(400).send('No file uploaded.'); + } + + // Handle the uploaded file + console.log('Uploaded file:', req.file); + + res.send('File uploaded successfully.'); +}); + +module.exports = router; diff --git a/src/api/deploy.js b/src/api/deploy.js new file mode 100644 index 0000000..a2014bc --- /dev/null +++ b/src/api/deploy.js @@ -0,0 +1,26 @@ +const logger = require('../config/logger'); +const {generateApiUrl, getCdnUrl} = require('./utils'); + +const deployEndpoint = async (files) => { + try { + const deployedFiles = files.map(file => ({ + deployedUrl: generateApiUrl('v3', file.file), + cdnUrl: getCdnUrl(), + ...file + })); + + return { + status: 200, + files: deployedFiles, + cdnBase: getCdnUrl() + }; + } catch (error) { + logger.error('Deploy error:', error); + return { + status: 500, + files: [] + }; + } +}; + +module.exports = {deployEndpoint}; \ No newline at end of file diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..a5e2342 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,85 @@ +const express = require('express'); +const {validateToken, validateRequest, getCdnUrl} = require('./utils'); +const {uploadEndpoint, handleUpload} = require('./upload'); +const logger = require('../config/logger'); + +const router = express.Router(); + +// Require valid API token for all routes +router.use((req, res, next) => { + const tokenCheck = validateToken(req); + if (tokenCheck.status !== 200) { + return res.status(tokenCheck.status).json(tokenCheck.body); + } + next(); +}); + +// Health check route +router.get('/health', (req, res) => { + res.status(200).json({ status: 'ok' }); +}); + +// Format response based on API version compatibility +const formatResponse = (results, version) => { + switch (version) { + case 1: + return results.map(r => r.url); + case 2: + return results.reduce((acc, r, i) => { + const fileName = r.url.split('/').pop(); + acc[`${i}${fileName}`] = r.url; + return acc; + }, {}); + default: + return { + files: results.map((r, i) => ({ + deployedUrl: r.url, + file: `${i}_${r.url.split('/').pop()}`, + sha: r.sha, + size: r.size + })), + cdnBase: getCdnUrl() + }; + } +}; + +// Handle bulk file uploads with version-specific responses +const handleBulkUpload = async (req, res, version) => { + try { + const urls = req.body; + // Basic validation + if (!Array.isArray(urls) || !urls.length) { + return res.status(422).json({error: 'Empty/invalid file array'}); + } + + // Process all URLs concurrently + logger.debug(`Processing ${urls.length} URLs`); + const results = await Promise.all( + urls.map(url => uploadEndpoint(url, req.headers?.authorization)) + ); + + res.json(formatResponse(results, version)); + } catch (error) { + logger.error('Bulk upload failed:', error); + res.status(500).json({error: 'Internal server error'}); + } +}; + +// API Routes +router.post('/v1/new', (req, res) => handleBulkUpload(req, res, 1)); // Legacy support +router.post('/v2/new', (req, res) => handleBulkUpload(req, res, 2)); // Legacy support +router.post('/v3/new', (req, res) => handleBulkUpload(req, res, 3)); // Current version +router.post('/new', (req, res) => handleBulkUpload(req, res, 3)); // Alias for v3 (latest) + +// Single file upload endpoint +router.post('/upload', async (req, res) => { + try { + const result = await handleUpload(req); + res.status(result.status).json(result.body); + } catch (error) { + logger.error('Upload handler error:', error); + res.status(500).json({error: 'Internal server error'}); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/api/upload.js b/src/api/upload.js new file mode 100644 index 0000000..c47bf45 --- /dev/null +++ b/src/api/upload.js @@ -0,0 +1,56 @@ +const fetch = require('node-fetch'); +const crypto = require('crypto'); +const {uploadToBackblaze} = require('../backblaze'); +const {generateUrl, getCdnUrl} = require('./utils'); +const logger = require('../config/logger'); + +// Sanitize file name for storage +function sanitizeFileName(fileName) { + let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + if (!sanitizedFileName) { + sanitizedFileName = 'upload_' + Date.now(); + } + return sanitizedFileName; +} + +// Handle remote file upload to B2 storage +const uploadEndpoint = async (url, authorization = null) => { + try { + logger.debug(`Downloading: ${url}`); + const response = await fetch(url, { + headers: authorization ? {'Authorization': authorization} : {} + }); + + if (!response.ok) throw new Error(`Download failed: ${response.statusText}`); + + // Generate unique filename using SHA1 (hash) of file contents + const buffer = await response.buffer(); + const sha = crypto.createHash('sha1').update(buffer).digest('hex'); + const originalName = url.split('/').pop(); + const sanitizedFileName = sanitizeFileName(originalName); + const fileName = `${sha}_${sanitizedFileName}`; + + // Upload to B2 storage + logger.debug(`Uploading: ${fileName}`); + const uploaded = await uploadToBackblaze('s/v3', fileName, buffer); + if (!uploaded) throw new Error('Storage upload failed'); + + return { + url: generateUrl('s/v3', fileName), + sha, + size: buffer.length + }; + } catch (error) { + logger.error('Upload failed:', error); + throw error; + } +}; + +// Express request handler for file uploads +const handleUpload = async (req) => { + const url = req.body || await req.text(); + const result = await uploadEndpoint(url, req.headers?.authorization); + return {status: 200, body: result}; +}; + +module.exports = {uploadEndpoint, handleUpload}; diff --git a/src/api/utils.js b/src/api/utils.js new file mode 100644 index 0000000..1ac651d --- /dev/null +++ b/src/api/utils.js @@ -0,0 +1,45 @@ +const logger = require('../config/logger'); + +const getCdnUrl = () => process.env.CDN_URL; + +const generateUrl = (version, fileName) => { + return `${getCdnUrl()}/${version}/${fileName}`; +}; + +const validateToken = (req) => { + const token = req.headers.authorization?.split('Bearer ')[1]; + if (!token || token !== process.env.API_TOKEN) { + return { + status: 401, + body: {error: 'Unauthorized - Invalid or missing API token'} + }; + } + return {status: 200}; +}; + +const validateRequest = (req) => { + // First check token + const tokenCheck = validateToken(req); + if (tokenCheck.status !== 200) { + return tokenCheck; + } + + // Then check method (copied the thing from old api maybe someone is insane and uses the status and not the code) + if (req.method === 'OPTIONS') { + return {status: 204, body: {status: 'YIPPE YAY. YOU HAVE CLEARANCE TO PROCEED.'}}; + } + if (req.method !== 'POST') { + return { + status: 405, + body: {error: 'Method not allowed, use POST'} + }; + } + return {status: 200}; +}; + +module.exports = { + validateRequest, + validateToken, + generateUrl, + getCdnUrl +}; diff --git a/src/backblaze.js b/src/backblaze.js new file mode 100644 index 0000000..15147e0 --- /dev/null +++ b/src/backblaze.js @@ -0,0 +1,32 @@ +const B2 = require('backblaze-b2'); +const logger = require('./config/logger'); + +const b2 = new B2({ + applicationKeyId: process.env.B2_APP_KEY_ID, + applicationKey: process.env.B2_APP_KEY +}); + +async function uploadToBackblaze(userDir, uniqueFileName, buffer) { + try { + await b2.authorize(); + const {data} = await b2.getUploadUrl({ + bucketId: process.env.B2_BUCKET_ID + }); + + await b2.uploadFile({ + uploadUrl: data.uploadUrl, + uploadAuthToken: data.authorizationToken, + fileName: `${userDir}/${uniqueFileName}`, + data: buffer + }); + + return true; + } catch (error) { + logger.error('B2 upload failed:', error.message); + return false; + } +} + +module.exports = {uploadToBackblaze}; + +// So easy i love it! \ No newline at end of file diff --git a/src/config/logger.js b/src/config/logger.js new file mode 100644 index 0000000..6a543f2 --- /dev/null +++ b/src/config/logger.js @@ -0,0 +1,23 @@ +const winston = require('winston'); + +const consoleFormat = winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.printf(({level, message, timestamp}) => { + return `${timestamp} ${level}: ${message}`; + }) +); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: consoleFormat, + transports: [ + new winston.transports.Console() + ] +}); + +logger.on('error', error => { + console.error('Logger error:', error); +}); + +module.exports = logger; \ No newline at end of file diff --git a/src/fileUpload.js b/src/fileUpload.js new file mode 100644 index 0000000..407f094 --- /dev/null +++ b/src/fileUpload.js @@ -0,0 +1,230 @@ +const fetch = require('node-fetch'); +const path = require('path'); +const crypto = require('crypto'); +const logger = require('./config/logger'); +const {uploadToBackblaze} = require('./backblaze'); +const {generateFileUrl} = require('./utils'); + +const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB in bytes +const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) + +// processed messages +const processedMessages = new Map(); + +let uploadLimit; + +async function initialize() { + const pLimit = (await import('p-limit')).default; + uploadLimit = pLimit(CONCURRENT_UPLOADS); +} + +// Check if the message is older than 24 hours for when the bot was offline +function isMessageTooOld(eventTs) { + const eventTime = parseFloat(eventTs) * 1000; + const currentTime = Date.now(); + const timeDifference = currentTime - eventTime; + const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + return timeDifference > maxAge; +} + +// check if the message has already been processed +function isMessageProcessed(messageTs) { + return processedMessages.has(messageTs); +} + +function markMessageAsProcessing(messageTs) { + processedMessages.set(messageTs, true); +} + +// Processing reaction +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 processing reaction:', error.message); + } +} + +// sanitize file names and ensure it's not empty (I don't even know if that's possible but let's be safe) +function sanitizeFileName(fileName) { + let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + if (!sanitizedFileName) { + sanitizedFileName = 'upload_' + Date.now(); + } + return sanitizedFileName; +} + +// Generate a unique, non-guessable file name +function generateUniqueFileName(fileName) { + const sanitizedFileName = sanitizeFileName(fileName); + const uniqueFileName = `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizedFileName}`; + return uniqueFileName; +} + +// upload files to the /s/ directory +async function processFiles(fileMessage, client) { + const uploadedFiles = []; + const failedFiles = []; + + const files = fileMessage.files || []; + for (const file of files) { + if (file.size > MAX_FILE_SIZE) { + failedFiles.push(file.name); + continue; + } + + try { + const buffer = await fetch(file.url_private, { + headers: {Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`} + }).then(res => res.buffer()); + + const uniqueFileName = generateUniqueFileName(file.name); + const userDir = `s/${fileMessage.user}`; + + const success = await uploadLimit(() => uploadToBackblaze(userDir, uniqueFileName, buffer)); + if (success) { + const url = generateFileUrl(userDir, uniqueFileName); + uploadedFiles.push({name: uniqueFileName, url}); + } else { + failedFiles.push(file.name); + } + } catch (error) { + logger.error(`Failed to process file ${file.name}:`, error.message); + failedFiles.push(file.name); + } + } + + return {uploadedFiles, failedFiles}; +} + +// update reactions based on success +async function updateReactions(client, event, fileMessage, success) { + try { + await client.reactions.remove({ + name: 'beachball', + timestamp: fileMessage.ts, + channel: event.channel_id + }); + await client.reactions.add({ + name: success ? 'white_check_mark' : 'x', + timestamp: fileMessage.ts, + channel: event.channel_id + }); + } catch (error) { + logger.error('Failed to update reactions:', error.message); + } +} + +// find a file 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 + 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) { + let message = `Hey <@${fileMessage.user}>, `; + if (uploadedFiles.length > 0) { + message += `here ${uploadedFiles.length === 1 ? 'is your link' : 'are your links'}:\n`; + message += uploadedFiles.map(f => `• ${f.name}: ${f.url}`).join('\n'); + } + if (failedFiles.length > 0) { + message += `\n\nFailed to process: ${failedFiles.join(', ')}`; + } + + await client.chat.postMessage({ + channel: channelId, + thread_ts: fileMessage.ts, + text: message + }); +} + +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} = await processFiles(fileMessage, client); + await sendResultsMessage(client, event.channel_id, fileMessage, uploadedFiles, failedFiles); + + await updateReactions(client, event, fileMessage, failedFiles.length === 0); + + } 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/upload.js b/src/upload.js new file mode 100644 index 0000000..b390b9f --- /dev/null +++ b/src/upload.js @@ -0,0 +1,32 @@ +const fs = require('fs'); +const path = require('path'); +const {uploadToBackblaze} = require('../backblaze'); +const {generateUrl} = require('./utils'); +const logger = require('../config/logger'); + +// Handle individual file upload +const handleUpload = async (file) => { + try { + const buffer = fs.readFileSync(file.path); + const fileName = path.basename(file.originalname); + const uniqueFileName = `${Date.now()}-${fileName}`; + + // Upload to B2 storage + logger.debug(`Uploading: ${uniqueFileName}`); + const uploaded = await uploadToBackblaze('s/v3', uniqueFileName, buffer); + if (!uploaded) throw new Error('Storage upload failed'); + + return { + name: fileName, + url: generateUrl('s/v3', uniqueFileName) + }; + } catch (error) { + logger.error('Upload failed:', error); + throw error; + } finally { + // Clean up the temporary file + fs.unlinkSync(file.path); + } +}; + +module.exports = {handleUpload}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..6a0003f --- /dev/null +++ b/src/utils.js @@ -0,0 +1,8 @@ +// Make the CDN URL + +function generateFileUrl(userDir, uniqueFileName) { + const cdnUrl = process.env.B2_CDN_URL; + return `${cdnUrl}/${userDir}/${uniqueFileName}`; +} + +module.exports = {generateFileUrl}; \ No newline at end of file diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 8d0efa0..0000000 --- a/vercel.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 2, - "functions": { - "api/**/*.[jt]s": { "runtime": "vercel-deno@3.0.0" } - }, - "redirects": [ - { "source": "/", "destination": "https://github.com/hackclub/cdn" }, - { "source": "/api/new", "destination": "/api/v1/new", "permanent": false }, - { "source": "/api/newSingle", "destination": "/api/v1/newSingle", "permanent": false } - ] -} From a5796ce7182c9d3e76d9a5e8fa01b71493eb2930 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Sat, 18 Jan 2025 14:09:57 +0100 Subject: [PATCH 02/11] Fixed Port and removed my name from package.json --- index.js | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 267b2ee..3216bbb 100644 --- a/index.js +++ b/index.js @@ -76,7 +76,7 @@ app.event('file_shared', async ({event, client}) => { try { await fileUpload.initialize(); await app.start(); - const port = parseInt(process.env.API_PORT || '4553', 10); + const port = parseInt(process.env.PORT || '4553', 10); expressApp.listen(port, () => { logger.info(`⚡️ Slack app is running in Socket Mode!`); logger.info(`🚀 API server is running on port ${port}`); diff --git a/package.json b/package.json index a571cb5..c409ad1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,6 @@ "p-limit": "^6.2.0", "winston": "^3.17.0" }, - "author": "deployor", + "author": "", "license": "MIT" } From d89aef400e32a2281dca82dfe349d405294e64ab Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Sat, 18 Jan 2025 14:12:44 +0100 Subject: [PATCH 03/11] Corrected description in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c409ad1..8ad828c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cdn-v2-hackclub", "version": "1.0.0", - "description": "Slack app to upload files to Backblaze B2 with unique URLs", + "description": "Slack app and API to upload files to Backblaze B2 with unique URLs", "main": "index.js", "scripts": { "start": "node index.js" From 23da294fa7cc1b1bed9c872589530fcec3797364 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Sat, 18 Jan 2025 14:26:03 +0100 Subject: [PATCH 04/11] Removed certain info logs. They were more debug than info! --- index.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index 3216bbb..eb1d8cd 100644 --- a/index.js +++ b/index.js @@ -23,15 +23,6 @@ expressApp.use(cors()); expressApp.use(express.json()); expressApp.use(express.urlencoded({ extended: true })); -// Log ALL incoming requests for debugging -expressApp.use((req, res, next) => { - logger.info(`Incoming request: ${req.method} ${req.path}`); - next(); -}); - -// Log statement before mounting the API routes -logger.info('Mounting API routes'); - // Mount API for all versions expressApp.use('/api', apiRoutes); @@ -52,7 +43,7 @@ app.event('file_shared', async ({event, client}) => { logger.debug(`Received file_shared event: ${JSON.stringify(event)}`); if (parseFloat(event.event_ts) < BOT_START_TIME) { - logger.info(`Ignoring file event from before bot start: ${new Date(parseFloat(event.event_ts) * 1000).toISOString()}`); + logger.debug(`Ignoring file event from before bot start: ${new Date(parseFloat(event.event_ts) * 1000).toISOString()}`); return; } @@ -60,7 +51,7 @@ app.event('file_shared', async ({event, client}) => { const channelId = event.channel_id; if (channelId !== targetChannelId) { - logger.info(`Ignoring file shared in channel: ${channelId}`); + logger.debug(`Ignoring file shared in channel: ${channelId}`); return; } From e38d8cccdc94ed0207935c3d8de5fd699b080820 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Sat, 18 Jan 2025 15:06:39 +0100 Subject: [PATCH 05/11] Fixed "undefined" CDN URL --- src/api/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/utils.js b/src/api/utils.js index 1ac651d..53b3d2f 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -1,6 +1,6 @@ const logger = require('../config/logger'); -const getCdnUrl = () => process.env.CDN_URL; +const getCdnUrl = () => process.env.B2_CDN_URL; const generateUrl = (version, fileName) => { return `${getCdnUrl()}/${version}/${fileName}`; From 779fe5a9eadc3c40db9e6d2b7d24ef2f5ccc2055 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Tue, 21 Jan 2025 21:15:55 +0100 Subject: [PATCH 06/11] Switched install instructions to Bun because we're modern like that! (and I'm too lazy for Deno) :) --- .gitignore | 3 +- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 01d6024..5cc472e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /splitfornpm/ /.idea/ /.env -/package-lock.json +/bun.lockb +/package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 3a883d1..93fcf98 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- + flag

CDN

A CDN solution for Hack Club!

@@ -7,7 +7,7 @@

Deep under the waves and storms there lies a vault...

- + Banner

Banner illustration by @maxwofford.

@@ -79,13 +79,45 @@ API_TOKEN=beans # Set a secure random string PORT=3000 ``` -### 4. Installation & Running +Here's an improved version of your README section with better clarity and formatting: + +--- + +### **4. Installation & Running** + +#### **Install Dependencies** +Make sure you have [Bun](https://bun.sh/) installed, then run: ```bash -npm install -node index.js +bun install +``` + +#### **Run the Application** +You can start the application using any of the following methods: + +```bash +# Using Node.js +node index.js + +# Using Bun +bun index.js + +# Using Bun with script +bun run start +``` + +#### **Using PM2 (Optional)** +For auto-starting the application, you can use PM2: + +```bash +pm2 start bun --name "HC-CDN1" -- run start + +# Optionally, save the process list +pm2 save + +# Optionally, generate startup script +pm2 startup ``` -Feel free to use pm2! ## 📡 API Usage @@ -121,16 +153,33 @@ curl --location 'https://e2.deployor.hackclub.app/api/v3/new' \ **Response:** ```json { - "files": [ - { - "deployedUrl": "https://cdn.deployor.dev/s/v3/3e48b91a4599a3841c028e9a683ef5ce58cea372_flag-standalone.svg", - "file": "0_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095691", - "sha": "16361167e11b0d172a47e726b40d70e9873c792b", - "size": 90173 - } - // Other files - ], - "cdnBase": "https://cdn.deployor.dev" + "files": [ + { + "deployedUrl": "https://cdn.deployor.dev/s/v3/3e48b91a4599a3841c028e9a683ef5ce58cea372_flag-standalone.svg", + "file": "0_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095691", + "sha": "16361167e11b0d172a47e726b40d70e9873c792b", + "size": 90173 + }, + { + "deployedUrl": "https://cdn.deployor.dev/s/v3/4e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-left.png", + "file": "1_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095692", + "sha": "16361167e11b0d172a47e726b40d70e9873c792b", + "size": 80234 + }, + { + "deployedUrl": "https://cdn.deployor.dev/s/v3/5e48b91a4599a3841c028e9a683ef5ce58cea372_icon-progress-marker.svg", + "file": "2_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095693", + "sha": "16361167e11b0d172a47e726b40d70e9873c792b", + "size": 70345 + }, + { + "deployedUrl": "https://cdn.deployor.dev/s/v3/6e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-right.png", + "file": "3_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095694", + "sha": "16361167e11b0d172a47e726b40d70e9873c792b", + "size": 60456 + } + ], + "cdnBase": "https://cdn.deployor.dev" } ``` From 495d1410a93e55c233efb06537e721b5fcb28d6c Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:18:53 +0100 Subject: [PATCH 07/11] Migrated fully to S3 API --- src/backblaze.js | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 src/backblaze.js diff --git a/src/backblaze.js b/src/backblaze.js deleted file mode 100644 index 15147e0..0000000 --- a/src/backblaze.js +++ /dev/null @@ -1,32 +0,0 @@ -const B2 = require('backblaze-b2'); -const logger = require('./config/logger'); - -const b2 = new B2({ - applicationKeyId: process.env.B2_APP_KEY_ID, - applicationKey: process.env.B2_APP_KEY -}); - -async function uploadToBackblaze(userDir, uniqueFileName, buffer) { - try { - await b2.authorize(); - const {data} = await b2.getUploadUrl({ - bucketId: process.env.B2_BUCKET_ID - }); - - await b2.uploadFile({ - uploadUrl: data.uploadUrl, - uploadAuthToken: data.authorizationToken, - fileName: `${userDir}/${uniqueFileName}`, - data: buffer - }); - - return true; - } catch (error) { - logger.error('B2 upload failed:', error.message); - return false; - } -} - -module.exports = {uploadToBackblaze}; - -// So easy i love it! \ No newline at end of file From 16c690583df9db964564f3081a41310d3068c273 Mon Sep 17 00:00:00 2001 From: "Tom (Whity)" <129990841+deployor@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:19:24 +0100 Subject: [PATCH 08/11] Migrated Fully to S3 Api 2 --- README.md | 94 +++++++------ index.js | 42 +++--- package.json | 4 +- src/api/deploy.js | 3 +- src/api/index.js | 4 +- src/api/upload.js | 63 +++++++-- src/api/utils.js | 2 +- src/config/logger.js | 30 ++--- src/fileUpload.js | 130 +++++++++--------- src/storage.js | 310 +++++++++++++++++++++++++++++++++++++++++++ src/upload.js | 11 +- src/utils.js | 2 +- 12 files changed, 526 insertions(+), 169 deletions(-) create mode 100644 src/storage.js diff --git a/README.md b/README.md index 93fcf98..1a6e014 100644 --- a/README.md +++ b/README.md @@ -44,19 +44,36 @@ 4. Enable Event Subscriptions and subscribe to `file_shared` event 5. Install the app to your workspace -### 2. CDN Configuration (Cloudflare + Backblaze) +### 2. Storage Configuration -1. Create a Backblaze B2 bucket -2. Set up Cloudflare DNS: - - Add a CNAME record pointing to your B2 bucket (e.g., `f003.backblazeb2.com`) you can upload a file and check in info! - - Enable Cloudflare proxy -3. Configure SSL/TLS: - - Set SSL mode to "Full (strict)" - - ⚠️ **WARNING**: This setting may break other configurations on your domain! You could use another domain! -4. Create a Transform Rule: - - Filter: `hostname equals "your-cdn.example.com"` - - Rewrite to: `concat("/file/(bucket name)", http.request.uri.path)` (make sure u get the bucket name) - - Preserve query string +This CDN supports any S3-compatible storage service. Here's how to set it up using Cloudflare R2 as an example: + +#### Setting up Cloudflare R2 (Example) + +1. **Create R2 Bucket** + - Go to Cloudflare Dashboard > R2 + - Click "Create Bucket" + - Name your bucket + - Enable public access + +2. **Generate API Credentials** + - Go to R2 + - Click "Manage API tokens" in API + - Click "Create API Token" + - Permissions: "Object Read & Write" + - Save both Access Key ID and Secret Access Key (S3) + +3. **Get Your URL** + - Go to R2 + - Click "Use R2 with APIs" in API + - Select S3 Compatible API + - The URL is your Endpoint + + +4. **Configure Custom Domain (Optional)** + - Go to R2 > Bucket Settings > Custom Domains + - Add your domain (e.g., cdn.beans.com) + - Follow DNS configuration steps ### 3. Environment Setup @@ -68,21 +85,19 @@ 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 -# Backblaze (Public Bucket) -B2_APP_KEY_ID=key-id # From B2 Application Keys -B2_APP_KEY=app-key # From B2 Application Keys -B2_BUCKET_ID=bucket-id # From B2 Bucket Settings -B2_CDN_URL=https://cdn.example.com +# S3 Config CF in this example +AWS_ACCESS_KEY_ID=1234567890abcdef +AWS_SECRET_ACCESS_KEY=abcdef1234567890 +AWS_BUCKET_NAME=my-cdn-bucket +AWS_REGION=auto +AWS_ENDPOINT=https://.r2.cloudflarestorage.com +AWS_CDN_URL=https://cdn.beans.com # API API_TOKEN=beans # Set a secure random string -PORT=3000 +PORT=3000 ``` -Here's an improved version of your README section with better clarity and formatting: - ---- - ### **4. Installation & Running** #### **Install Dependencies** @@ -130,7 +145,7 @@ pm2 startup ### V3 API (Latest) Version 3 -**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v3/new` +**Endpoint:** `POST https://e2.example.hackclub.app/api/v3/new` **Headers:** ``` @@ -140,7 +155,7 @@ Content-Type: application/json **Request Example:** ```bash -curl --location 'https://e2.deployor.hackclub.app/api/v3/new' \ +curl --location 'https://e2.example.hackclub.app/api/v3/new' \ --header 'Authorization: Bearer beans' \ --header 'Content-Type: application/json' \ --data '[ @@ -155,31 +170,31 @@ curl --location 'https://e2.deployor.hackclub.app/api/v3/new' \ { "files": [ { - "deployedUrl": "https://cdn.deployor.dev/s/v3/3e48b91a4599a3841c028e9a683ef5ce58cea372_flag-standalone.svg", + "deployedUrl": "https://cdn.example.dev/s/v3/3e48b91a4599a3841c028e9a683ef5ce58cea372_flag-standalone.svg", "file": "0_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095691", "sha": "16361167e11b0d172a47e726b40d70e9873c792b", "size": 90173 }, { - "deployedUrl": "https://cdn.deployor.dev/s/v3/4e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-left.png", + "deployedUrl": "https://cdn.example.dev/s/v3/4e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-left.png", "file": "1_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095692", "sha": "16361167e11b0d172a47e726b40d70e9873c792b", "size": 80234 }, { - "deployedUrl": "https://cdn.deployor.dev/s/v3/5e48b91a4599a3841c028e9a683ef5ce58cea372_icon-progress-marker.svg", + "deployedUrl": "https://cdn.example.dev/s/v3/5e48b91a4599a3841c028e9a683ef5ce58cea372_icon-progress-marker.svg", "file": "2_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095693", "sha": "16361167e11b0d172a47e726b40d70e9873c792b", "size": 70345 }, { - "deployedUrl": "https://cdn.deployor.dev/s/v3/6e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-right.png", + "deployedUrl": "https://cdn.example.dev/s/v3/6e48b91a4599a3841c028e9a683ef5ce58cea372_flag-orpheus-right.png", "file": "3_16361167e11b0d172a47e726b40d70e9873c792b_upload_1736985095694", "sha": "16361167e11b0d172a47e726b40d70e9873c792b", "size": 60456 } ], - "cdnBase": "https://cdn.deployor.dev" + "cdnBase": "https://cdn.example.dev" } ``` @@ -188,7 +203,7 @@ curl --location 'https://e2.deployor.hackclub.app/api/v3/new' \ Version 2 -**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v2/new` +**Endpoint:** `POST https://e2.example.hackclub.app/api/v2/new` **Headers:** ``` @@ -208,9 +223,9 @@ Content-Type: application/json **Response:** ```json { - "flag-standalone.svg": "https://cdn.deployor.dev/s/v2/flag-standalone.svg", - "flag-orpheus-left.png": "https://cdn.deployor.dev/s/v2/flag-orpheus-left.png", - "icon-progress-marker.svg": "https://cdn.deployor.dev/s/v2/icon-progress-marker.svg" + "flag-standalone.svg": "https://cdn.example.dev/s/v2/flag-standalone.svg", + "flag-orpheus-left.png": "https://cdn.example.dev/s/v2/flag-orpheus-left.png", + "icon-progress-marker.svg": "https://cdn.example.dev/s/v2/icon-progress-marker.svg" } ``` @@ -220,7 +235,7 @@ Content-Type: application/json Version 1 -**Endpoint:** `POST https://e2.deployor.hackclub.app/api/v1/new` +**Endpoint:** `POST https://e2.example.hackclub.app/api/v1/new` **Headers:** ``` @@ -240,9 +255,9 @@ Content-Type: application/json **Response:** ```json [ - "https://cdn.deployor.dev/s/v1/0_flag-standalone.svg", - "https://cdn.deployor.dev/s/v1/1_flag-orpheus-left.png", - "https://cdn.deployor.dev/s/v1/2_icon-progress-marker.svg" + "https://cdn.example.dev/s/v1/0_flag-standalone.svg", + "https://cdn.example.dev/s/v1/1_flag-orpheus-left.png", + "https://cdn.example.dev/s/v1/2_icon-progress-marker.svg" ] ``` @@ -264,7 +279,7 @@ Content-Type: application/json - **Storage Structure:** `/s/v3/{HASH}_{filename}` - **File Naming:** `/s/{slackUserId}/{unix}_{sanitizedFilename}` -- **Cost Efficiency:** Uses B2 storage for significant cost savings +- **Cost Efficiency:** Uses object storage for significant cost savings - **Security:** Token-based authentication for API access ## 💻 Slack Bot Behavior @@ -279,8 +294,7 @@ Content-Type: application/json ## 💰 Cost Optimization -- Uses Cloudflare CDN with Backblaze B2 storage -- Free egress thanks to Cloudflare-Backblaze Alliance +- Uses Object storage - 87-98% cost reduction compared to Vercel CDN
diff --git a/index.js b/index.js index eb1d8cd..482ece9 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,9 @@ 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'); @@ -28,7 +31,12 @@ expressApp.use('/api', apiRoutes); // Error handling middleware expressApp.use((err, req, res, next) => { - logger.error('API Error:', err); + logger.error('API Error:', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method + }); res.status(500).json({ error: 'Internal server error' }); }); @@ -40,40 +48,34 @@ expressApp.use((req, res, next) => { // Event listener for file_shared events app.event('file_shared', async ({event, client}) => { - logger.debug(`Received file_shared event: ${JSON.stringify(event)}`); - - if (parseFloat(event.event_ts) < BOT_START_TIME) { - logger.debug(`Ignoring file event from before bot start: ${new Date(parseFloat(event.event_ts) * 1000).toISOString()}`); - return; - } - - const targetChannelId = process.env.SLACK_CHANNEL_ID; - const channelId = event.channel_id; - - if (channelId !== targetChannelId) { - logger.debug(`Ignoring file shared in channel: ${channelId}`); - return; - } + 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(`Error processing file upload: ${error.message}`); + logger.error(`Upload failed: ${error.message}`); } }); -// Slack bot and API server +// Startup LOGs (async () => { try { await fileUpload.initialize(); await app.start(); const port = parseInt(process.env.PORT || '4553', 10); expressApp.listen(port, () => { - logger.info(`⚡️ Slack app is running in Socket Mode!`); - logger.info(`🚀 API server is running on port ${port}`); + logger.info('CDN started successfully 🔥', { + slackMode: 'Socket Mode', + apiPort: port, + startTime: new Date().toISOString() + }); }); } catch (error) { - logger.error('Failed to start:', error); + logger.error('Failed to start application:', { + error: error.message, + stack: error.stack + }); process.exit(1); } })(); diff --git a/package.json b/package.json index 8ad828c..9003d87 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "cdn-v2-hackclub", "version": "1.0.0", - "description": "Slack app and API to upload files to Backblaze B2 with unique URLs", + "description": "Slack app and API to upload files to S3-compatible storage with unique URLs", "main": "index.js", "scripts": { "start": "node index.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.478.0", "@slack/bolt": "^4.2.0", "@slack/web-api": "^7.8.0", - "backblaze-b2": "^1.3.0", "cors": "^2.8.5", "dotenv": "^10.0.0", "multer": "^1.4.5-lts.1", diff --git a/src/api/deploy.js b/src/api/deploy.js index a2014bc..5c5b4f1 100644 --- a/src/api/deploy.js +++ b/src/api/deploy.js @@ -6,6 +6,7 @@ const deployEndpoint = async (files) => { const deployedFiles = files.map(file => ({ deployedUrl: generateApiUrl('v3', file.file), cdnUrl: getCdnUrl(), + contentType: file.contentType || 'application/octet-stream', ...file })); @@ -15,7 +16,7 @@ const deployEndpoint = async (files) => { cdnBase: getCdnUrl() }; } catch (error) { - logger.error('Deploy error:', error); + logger.error('S3 deploy error:', error); return { status: 500, files: [] diff --git a/src/api/index.js b/src/api/index.js index a5e2342..4d2bf25 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -77,8 +77,8 @@ router.post('/upload', async (req, res) => { const result = await handleUpload(req); res.status(result.status).json(result.body); } catch (error) { - logger.error('Upload handler error:', error); - res.status(500).json({error: 'Internal server error'}); + logger.error('S3 upload handler error:', error); + res.status(500).json({error: 'Storage upload failed'}); } }); diff --git a/src/api/upload.js b/src/api/upload.js index c47bf45..dbdd1e2 100644 --- a/src/api/upload.js +++ b/src/api/upload.js @@ -1,6 +1,6 @@ const fetch = require('node-fetch'); const crypto = require('crypto'); -const {uploadToBackblaze} = require('../backblaze'); +const {uploadToStorage} = require('../storage'); const {generateUrl, getCdnUrl} = require('./utils'); const logger = require('../config/logger'); @@ -13,15 +13,19 @@ function sanitizeFileName(fileName) { return sanitizedFileName; } -// Handle remote file upload to B2 storage +// Handle remote file upload to S3 storage const uploadEndpoint = async (url, authorization = null) => { try { - logger.debug(`Downloading: ${url}`); + logger.debug('Starting download', { url }); const response = await fetch(url, { headers: authorization ? {'Authorization': authorization} : {} }); - if (!response.ok) throw new Error(`Download failed: ${response.statusText}`); + if (!response.ok) { + const error = new Error(`Download failed: ${response.statusText}`); + error.statusCode = response.status; + throw error; + } // Generate unique filename using SHA1 (hash) of file contents const buffer = await response.buffer(); @@ -30,27 +34,60 @@ const uploadEndpoint = async (url, authorization = null) => { const sanitizedFileName = sanitizeFileName(originalName); const fileName = `${sha}_${sanitizedFileName}`; - // Upload to B2 storage + // Upload to S3 storage logger.debug(`Uploading: ${fileName}`); - const uploaded = await uploadToBackblaze('s/v3', fileName, buffer); - if (!uploaded) throw new Error('Storage upload failed'); + const uploadResult = await uploadToStorage('s/v3', fileName, buffer, response.headers.get('content-type')); + if (uploadResult.success === false) { + throw new Error(`Storage upload failed: ${uploadResult.error}`); + } return { url: generateUrl('s/v3', fileName), sha, - size: buffer.length + size: buffer.length, + type: response.headers.get('content-type') }; } catch (error) { - logger.error('Upload failed:', error); - throw error; + logger.error('Upload process failed', { + url, + error: error.message, + statusCode: error.statusCode, + stack: error.stack + }); + + // Format error (pain) + const statusCode = error.statusCode || 500; + const errorResponse = { + error: { + message: error.message, + code: error.code || 'INTERNAL_ERROR', + details: error.details || null + }, + success: false + }; + + throw { statusCode, ...errorResponse }; } }; // Express request handler for file uploads const handleUpload = async (req) => { - const url = req.body || await req.text(); - const result = await uploadEndpoint(url, req.headers?.authorization); - return {status: 200, body: result}; + try { + const url = req.body || await req.text(); + const result = await uploadEndpoint(url, req.headers?.authorization); + return { status: 200, body: result }; + } catch (error) { + return { + status: error.statusCode || 500, + body: { + error: error.error || { + message: 'Internal server error', + code: 'INTERNAL_ERROR' + }, + success: false + } + }; + } }; module.exports = {uploadEndpoint, handleUpload}; diff --git a/src/api/utils.js b/src/api/utils.js index 53b3d2f..4ce7c20 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -1,6 +1,6 @@ const logger = require('../config/logger'); -const getCdnUrl = () => process.env.B2_CDN_URL; +const getCdnUrl = () => process.env.AWS_CDN_URL; const generateUrl = (version, fileName) => { return `${getCdnUrl()}/${version}/${fileName}`; diff --git a/src/config/logger.js b/src/config/logger.js index 6a543f2..6b9ca0a 100644 --- a/src/config/logger.js +++ b/src/config/logger.js @@ -1,23 +1,19 @@ const winston = require('winston'); -const consoleFormat = winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.printf(({level, message, timestamp}) => { - return `${timestamp} ${level}: ${message}`; - }) -); - const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: consoleFormat, - transports: [ - new winston.transports.Console() - ] -}); - -logger.on('error', error => { - console.error('Logger error:', error); + level: 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.colorize(), + winston.format.printf(({ level, message, timestamp, ...meta }) => { + let output = `${timestamp} ${level}: ${message}`; + if (Object.keys(meta).length > 0) { + output += ` ${JSON.stringify(meta)}`; + } + return output; + }) + ), + transports: [new winston.transports.Console()] }); module.exports = logger; \ No newline at end of file diff --git a/src/fileUpload.js b/src/fileUpload.js index 407f094..458f894 100644 --- a/src/fileUpload.js +++ b/src/fileUpload.js @@ -1,16 +1,13 @@ const fetch = require('node-fetch'); -const path = require('path'); const crypto = require('crypto'); const logger = require('./config/logger'); -const {uploadToBackblaze} = require('./backblaze'); +const storage = require('./storage'); const {generateFileUrl} = require('./utils'); const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB in bytes const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) -// processed messages const processedMessages = new Map(); - let uploadLimit; async function initialize() { @@ -18,16 +15,12 @@ async function initialize() { uploadLimit = pLimit(CONCURRENT_UPLOADS); } -// Check if the message is older than 24 hours for when the bot was offline +// Basic stuff function isMessageTooOld(eventTs) { const eventTime = parseFloat(eventTs) * 1000; - const currentTime = Date.now(); - const timeDifference = currentTime - eventTime; - const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - return timeDifference > maxAge; + return (Date.now() - eventTime) > 24 * 60 * 60 * 1000; } -// check if the message has already been processed function isMessageProcessed(messageTs) { return processedMessages.has(messageTs); } @@ -36,7 +29,63 @@ function markMessageAsProcessing(messageTs) { processedMessages.set(messageTs, true); } -// Processing reaction +// 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 = []; + + logger.info(`Processing ${fileMessage.files?.length || 0} files`); + + for (const file of fileMessage.files || []) { + try { + if (file.size > MAX_FILE_SIZE) { + failedFiles.push(file.name); + continue; + } + + 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, + url: generateFileUrl(userDir, uniqueFileName), + contentType: file.mimetype + }); + + } catch (error) { + logger.error(`Failed: ${file.name} - ${error.message}`); + failedFiles.push(file.name); + } + } + + logger.info(`Completed: ${uploadedFiles.length} ok, ${failedFiles.length} failed`); + return {uploadedFiles, failedFiles}; +} + +// Slack interaction async function addProcessingReaction(client, event, fileMessage) { try { await client.reactions.add({ @@ -45,63 +94,10 @@ async function addProcessingReaction(client, event, fileMessage) { channel: event.channel_id }); } catch (error) { - logger.error('Failed to add processing reaction:', error.message); + logger.error('Failed to add reaction:', error.message); } } -// sanitize file names and ensure it's not empty (I don't even know if that's possible but let's be safe) -function sanitizeFileName(fileName) { - let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - if (!sanitizedFileName) { - sanitizedFileName = 'upload_' + Date.now(); - } - return sanitizedFileName; -} - -// Generate a unique, non-guessable file name -function generateUniqueFileName(fileName) { - const sanitizedFileName = sanitizeFileName(fileName); - const uniqueFileName = `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizedFileName}`; - return uniqueFileName; -} - -// upload files to the /s/ directory -async function processFiles(fileMessage, client) { - const uploadedFiles = []; - const failedFiles = []; - - const files = fileMessage.files || []; - for (const file of files) { - if (file.size > MAX_FILE_SIZE) { - failedFiles.push(file.name); - continue; - } - - try { - const buffer = await fetch(file.url_private, { - headers: {Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`} - }).then(res => res.buffer()); - - const uniqueFileName = generateUniqueFileName(file.name); - const userDir = `s/${fileMessage.user}`; - - const success = await uploadLimit(() => uploadToBackblaze(userDir, uniqueFileName, buffer)); - if (success) { - const url = generateFileUrl(userDir, uniqueFileName); - uploadedFiles.push({name: uniqueFileName, url}); - } else { - failedFiles.push(file.name); - } - } catch (error) { - logger.error(`Failed to process file ${file.name}:`, error.message); - failedFiles.push(file.name); - } - } - - return {uploadedFiles, failedFiles}; -} - -// update reactions based on success async function updateReactions(client, event, fileMessage, success) { try { await client.reactions.remove({ @@ -119,7 +115,6 @@ async function updateReactions(client, event, fileMessage, success) { } } -// find a file message async function findFileMessage(event, client) { try { const fileInfo = await client.files.info({ @@ -217,11 +212,10 @@ async function handleFileUpload(event, client) { const {uploadedFiles, failedFiles} = await processFiles(fileMessage, client); await sendResultsMessage(client, event.channel_id, fileMessage, uploadedFiles, failedFiles); - await updateReactions(client, event, fileMessage, failedFiles.length === 0); } catch (error) { - logger.error('Upload failed:', error.message); + logger.error(`Upload failed: ${error.message}`); await handleError(client, event.channel_id, fileMessage, reactionAdded); throw error; } diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..dbb755f --- /dev/null +++ b/src/storage.js @@ -0,0 +1,310 @@ +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'); + +const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB in bytes +const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) + +// processed messages +const processedMessages = new Map(); + +let uploadLimit; + +async function initialize() { + const pLimit = (await import('p-limit')).default; + uploadLimit = pLimit(CONCURRENT_UPLOADS); +} + +// Check if the message is older than 24 hours for when the bot was offline +function isMessageTooOld(eventTs) { + const eventTime = parseFloat(eventTs) * 1000; + const currentTime = Date.now(); + const timeDifference = currentTime - eventTime; + const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + return timeDifference > maxAge; +} + +// check if the message has already been processed +function isMessageProcessed(messageTs) { + return processedMessages.has(messageTs); +} + +function markMessageAsProcessing(messageTs) { + processedMessages.set(messageTs, true); +} + +// Processing reaction +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 processing reaction:', error.message); + } +} + +// sanitize file names and ensure it's not empty (I don't even know if that's possible but let's be safe) +function sanitizeFileName(fileName) { + let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + if (!sanitizedFileName) { + sanitizedFileName = 'upload_' + Date.now(); + } + return sanitizedFileName; +} + +// Generate a unique, non-guessable file name +function generateUniqueFileName(fileName) { + const sanitizedFileName = sanitizeFileName(fileName); + const uniqueFileName = `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizedFileName}`; + return uniqueFileName; +} + +// upload files to the /s/ directory +async function processFiles(fileMessage, client) { + const uploadedFiles = []; + const failedFiles = []; + + logger.debug('Starting file processing', { + userId: fileMessage.user, + fileCount: fileMessage.files?.length || 0 + }); + + const files = fileMessage.files || []; + for (const file of files) { + logger.debug('Processing file', { + name: file.name, + size: file.size, + type: file.mimetype, + id: file.id + }); + + if (file.size > MAX_FILE_SIZE) { + logger.warn('File exceeds size limit', { + name: file.name, + size: file.size, + limit: MAX_FILE_SIZE + }); + failedFiles.push(file.name); + continue; + } + + try { + logger.debug('Fetching file from Slack', { + name: file.name, + url: file.url_private + }); + + const response = await fetch(file.url_private, { + headers: {Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`} + }); + + if (!response.ok) { + throw new Error(`Slack download failed: ${response.status} ${response.statusText}`); + } + + const buffer = await response.buffer(); + const contentType = file.mimetype || 'application/octet-stream'; + const uniqueFileName = generateUniqueFileName(file.name); + const userDir = `s/${fileMessage.user}`; + + const uploadResult = await uploadLimit(() => + uploadToStorage(userDir, uniqueFileName, buffer, contentType) + ); + + if (uploadResult.success === false) { + throw new Error(uploadResult.error); + } + + const url = generateFileUrl(userDir, uniqueFileName); + uploadedFiles.push({ + name: uniqueFileName, + url, + contentType + }); + } catch (error) { + logger.error('File processing failed', { + fileName: file.name, + error: error.message, + stack: error.stack, + slackFileId: file.id, + userId: fileMessage.user + }); + failedFiles.push(file.name); + } + } + + logger.debug('File processing complete', { + successful: uploadedFiles.length, + failed: failedFiles.length + }); + + return {uploadedFiles, failedFiles}; +} + +// update reactions based on success +async function updateReactions(client, event, fileMessage, success) { + try { + await client.reactions.remove({ + name: 'beachball', + timestamp: fileMessage.ts, + channel: event.channel_id + }); + await client.reactions.add({ + name: success ? 'white_check_mark' : 'x', + timestamp: fileMessage.ts, + channel: event.channel_id + }); + } catch (error) { + logger.error('Failed to update reactions:', error.message); + } +} + +// find a file 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 + 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) { + let message = `Hey <@${fileMessage.user}>, `; + if (uploadedFiles.length > 0) { + message += `here ${uploadedFiles.length === 1 ? 'is your link' : 'are your links'}:\n`; + message += uploadedFiles.map(f => `• ${f.name}: ${f.url}`).join('\n'); + } + if (failedFiles.length > 0) { + message += `\n\nFailed to process: ${failedFiles.join(', ')}`; + } + + await client.chat.postMessage({ + channel: channelId, + thread_ts: fileMessage.ts, + text: message + }); +} + +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} = await processFiles(fileMessage, client); + await sendResultsMessage(client, event.channel_id, fileMessage, uploadedFiles, failedFiles); + + await updateReactions(client, event, fileMessage, failedFiles.length === 0); + + } catch (error) { + logger.error('Upload failed:', error.message); + await handleError(client, event.channel_id, fileMessage, reactionAdded); + throw error; + } +} + +const s3Client = new S3Client({ + region: process.env.AWS_REGION, + endpoint: process.env.AWS_ENDPOINT, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY + } +}); + +async function uploadToStorage(userDir, uniqueFileName, buffer, contentType = 'application/octet-stream') { + try { + const params = { + Bucket: process.env.AWS_BUCKET_NAME, + Key: `${userDir}/${uniqueFileName}`, + Body: buffer, + ContentType: contentType, + CacheControl: 'public, immutable, max-age=31536000' + }; + + logger.info(`Uploading: ${uniqueFileName}`); + await s3Client.send(new PutObjectCommand(params)); + return true; + } catch (error) { + logger.error(`Upload failed: ${error.message}`, { + path: `${userDir}/${uniqueFileName}`, + error: error.message + }); + return false; + } +} + +module.exports = { + handleFileUpload, + initialize, + uploadToStorage +}; diff --git a/src/upload.js b/src/upload.js index b390b9f..167d897 100644 --- a/src/upload.js +++ b/src/upload.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const {uploadToBackblaze} = require('../backblaze'); +const {uploadToStorage} = require('../storage'); const {generateUrl} = require('./utils'); const logger = require('../config/logger'); @@ -9,16 +9,19 @@ const handleUpload = async (file) => { try { const buffer = fs.readFileSync(file.path); const fileName = path.basename(file.originalname); + // Add content type detection for S3 + const contentType = file.mimetype || 'application/octet-stream'; const uniqueFileName = `${Date.now()}-${fileName}`; - // Upload to B2 storage + // Upload to S3 storage with content type logger.debug(`Uploading: ${uniqueFileName}`); - const uploaded = await uploadToBackblaze('s/v3', uniqueFileName, buffer); + const uploaded = await uploadToStorage('s/v3', uniqueFileName, buffer, contentType); if (!uploaded) throw new Error('Storage upload failed'); return { name: fileName, - url: generateUrl('s/v3', uniqueFileName) + url: generateUrl('s/v3', uniqueFileName), + contentType }; } catch (error) { logger.error('Upload failed:', error); diff --git a/src/utils.js b/src/utils.js index 6a0003f..b3fabf8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ // Make the CDN URL function generateFileUrl(userDir, uniqueFileName) { - const cdnUrl = process.env.B2_CDN_URL; + const cdnUrl = process.env.AWS_CDN_URL; return `${cdnUrl}/${userDir}/${uniqueFileName}`; } From 0655a0d8a7bca9928feb0800210d28fc4b4c7a22 Mon Sep 17 00:00:00 2001 From: Deployor <129990841+deployor@users.noreply.github.com> Date: Thu, 20 Feb 2025 01:12:28 +0100 Subject: [PATCH 09/11] Added the Fun lines and error images! Also added partitial error support! --- .gitignore | 3 +- src/config/messages.js | 137 +++++++++++++++++++++++++++++++++++++++++ src/fileUpload.js | 126 +++++++++++++++++++++++++++++-------- src/storage.js | 2 +- src/upload.js | 4 +- 5 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 src/config/messages.js diff --git a/.gitignore b/.gitignore index 5cc472e..a3b3a54 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /.idea/ /.env /bun.lockb -/package-lock.json \ No newline at end of file +/package-lock.json +/.history \ No newline at end of file diff --git a/src/config/messages.js b/src/config/messages.js new file mode 100644 index 0000000..cb579e3 --- /dev/null +++ b/src/config/messages.js @@ -0,0 +1,137 @@ +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 index 458f894..3a3e616 100644 --- a/src/fileUpload.js +++ b/src/fileUpload.js @@ -3,8 +3,15 @@ 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 = 2 * 1024 * 1024 * 1024; // 2GB in bytes +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB in bytes const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) const processedMessages = new Map(); @@ -43,16 +50,23 @@ function generateUniqueFileName(fileName) { 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) { - failedFiles.push(file.name); + 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}`} }); @@ -71,6 +85,7 @@ async function processFiles(fileMessage, client) { uploadedFiles.push({ name: uniqueFileName, + originalName: file.name, url: generateFileUrl(userDir, uniqueFileName), contentType: file.mimetype }); @@ -81,8 +96,12 @@ async function processFiles(fileMessage, client) { } } - logger.info(`Completed: ${uploadedFiles.length} ok, ${failedFiles.length} failed`); - return {uploadedFiles, failedFiles}; + return { + uploadedFiles, + failedFiles, + sizeFailedFiles, + isSizeError: sizeFailedFiles.length > 0 + }; } // Slack interaction @@ -98,15 +117,26 @@ async function addProcessingReaction(client, event, fileMessage) { } } -async function updateReactions(client, event, fileMessage, success) { +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: success ? 'white_check_mark' : 'x', + name: reactionName, timestamp: fileMessage.ts, channel: event.channel_id }); @@ -133,7 +163,7 @@ async function findFileMessage(event, client) { throw new Error('No share info found for this channel'); } - // Get the exact message using the ts from share info + // Get the EXACT message using the ts from share info (channelShare) const messageTs = channelShare[0].ts; const messageInfo = await client.conversations.history({ @@ -154,21 +184,51 @@ async function findFileMessage(event, client) { } } -async function sendResultsMessage(client, channelId, fileMessage, uploadedFiles, failedFiles) { - let message = `Hey <@${fileMessage.user}>, `; - if (uploadedFiles.length > 0) { - message += `here ${uploadedFiles.length === 1 ? 'is your link' : 'are your links'}:\n`; - message += uploadedFiles.map(f => `• ${f.name}: ${f.url}`).join('\n'); - } - if (failedFiles.length > 0) { - message += `\n\nFailed to process: ${failedFiles.join(', ')}`; - } +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 + ); + } - await client.chat.postMessage({ - channel: channelId, - thread_ts: fileMessage.ts, - text: message - }); + 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) { @@ -210,9 +270,27 @@ async function handleFileUpload(event, client) { await addProcessingReaction(client, event, fileMessage); reactionAdded = true; - const {uploadedFiles, failedFiles} = await processFiles(fileMessage, client); - await sendResultsMessage(client, event.channel_id, fileMessage, uploadedFiles, failedFiles); - await updateReactions(client, event, fileMessage, failedFiles.length === 0); + 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}`); diff --git a/src/storage.js b/src/storage.js index dbb755f..0968212 100644 --- a/src/storage.js +++ b/src/storage.js @@ -57,7 +57,7 @@ function sanitizeFileName(fileName) { return sanitizedFileName; } -// Generate a unique, non-guessable file name +// Generate a unique file name function generateUniqueFileName(fileName) { const sanitizedFileName = sanitizeFileName(fileName); const uniqueFileName = `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizedFileName}`; diff --git a/src/upload.js b/src/upload.js index 167d897..fa73ab2 100644 --- a/src/upload.js +++ b/src/upload.js @@ -9,11 +9,11 @@ const handleUpload = async (file) => { try { const buffer = fs.readFileSync(file.path); const fileName = path.basename(file.originalname); - // Add content type detection for S3 + // content type detection for S3 const contentType = file.mimetype || 'application/octet-stream'; const uniqueFileName = `${Date.now()}-${fileName}`; - // Upload to S3 storage with content type + // Upload to S3 logger.debug(`Uploading: ${uniqueFileName}`); const uploaded = await uploadToStorage('s/v3', uniqueFileName, buffer, contentType); if (!uploaded) throw new Error('Storage upload failed'); From d9901a4285905690908e6d15f914eab89c2facd2 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 24 Feb 2025 20:33:38 -0500 Subject: [PATCH 10/11] Create Dockerfile --- Dockerfile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cd891d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use the official Bun image as base +FROM oven/bun:1 + +# Set working directory +WORKDIR /app + +# Copy package.json and bun.lockb (if exists) +COPY package*.json bun.lockb* ./ + +# Install dependencies +RUN bun install + +# Copy the rest of the application +COPY . . + +# Expose the port your Express server runs on +EXPOSE 3000 + +# Start the server +CMD ["bun", "run", "start"] From d09d2934a30056f52e8beb5c17c2ebe06c54c327 Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 24 Feb 2025 20:33:51 -0500 Subject: [PATCH 11/11] Setup .env.example --- .env.example | 17 +++++++++++++++++ README.md | 29 +++++------------------------ 2 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..77c617d --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# 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 +AWS_BUCKET_NAME=my-cdn-bucket +AWS_REGION=auto +AWS_ENDPOINT=https://.r2.cloudflarestorage.com +AWS_CDN_URL=https://cdn.beans.com + +# API +API_TOKEN=beans # Set a secure random string +PORT=3000 \ No newline at end of file diff --git a/README.md b/README.md index 1a6e014..b136e0b 100644 --- a/README.md +++ b/README.md @@ -77,26 +77,7 @@ This CDN supports any S3-compatible storage service. Here's how to set it up usi ### 3. Environment Setup -Create a `.env` file with: -```env -# 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 -AWS_BUCKET_NAME=my-cdn-bucket -AWS_REGION=auto -AWS_ENDPOINT=https://.r2.cloudflarestorage.com -AWS_CDN_URL=https://cdn.beans.com - -# API -API_TOKEN=beans # Set a secure random string -PORT=3000 -``` +Check out the `example.env` file for getting started! ### **4. Installation & Running** @@ -145,7 +126,7 @@ pm2 startup ### V3 API (Latest) Version 3 -**Endpoint:** `POST https://e2.example.hackclub.app/api/v3/new` +**Endpoint:** `POST https://cdn.hackclub.com/api/v3/new` **Headers:** ``` @@ -155,7 +136,7 @@ Content-Type: application/json **Request Example:** ```bash -curl --location 'https://e2.example.hackclub.app/api/v3/new' \ +curl --location 'https://cdn.hackclub.com/api/v3/new' \ --header 'Authorization: Bearer beans' \ --header 'Content-Type: application/json' \ --data '[ @@ -203,7 +184,7 @@ curl --location 'https://e2.example.hackclub.app/api/v3/new' \ Version 2 -**Endpoint:** `POST https://e2.example.hackclub.app/api/v2/new` +**Endpoint:** `POST https://cdn.hackclub.com/api/v2/new` **Headers:** ``` @@ -235,7 +216,7 @@ Content-Type: application/json Version 1 -**Endpoint:** `POST https://e2.example.hackclub.app/api/v1/new` +**Endpoint:** `POST https://cdn.hackclub.com/api/v1/new` **Headers:** ```