diff --git a/README.md b/README.md
index 33da9a3..6c41f74 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,15 @@
[](https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions)
-# Github Action for sending message (and reactions/threads/update/blocks) to Slack
+# Github Action for sending message (and reactions/threads/update/blocks/file uploads) to Slack
— With support for Slack's optional arguments


-This Action allows you to send messages (and reactions/threads/update/blocks) to Slack from your Github Actions. Supports Slack's required arguments as well as all the optional once. It's JavaScript-based and thus fast to run.
+This Action allows you to send messages (and reactions/threads/update/blocks/file uploads) to Slack from your Github Actions. Supports Slack's required arguments as well as all the optional once. It's JavaScript-based and thus fast to run.
-The goal is to have zero npm/yarn dependencies except `@actions/core` which is required for an action to work.
+The goal is to have zero npm production dependencies except `@actions/core` which is required for an action to work.

@@ -39,6 +39,9 @@ This action supports:
- 3. Send reaction on sent messages
+- 4. Upload files
+
+
## 1. Send messages to Slack
**Required: Github Repository Secret:**
@@ -303,6 +306,66 @@ For some examples, please see:
- [.github/workflows/11-slack-message-blocks.yml](.github/workflows/11-slack-message-blocks.yml)
- [.github/workflows/12-slack-message-blocks-update.yml](.github/workflows/12-slack-message-blocks-update.yml)
+## 6. Upload files
+
+Upload files to a Slack channel using the `upload-file` function.
+
+**Required: Github Action Parameters:**
+
+- `slack-bot-user-oauth-access-token` - `SLACK_BOT_USER_OAUTH_ACCESS_TOKEN` secret
+
+- `slack-channel` - The channel where you want to upload the file
+
+- `slack-upload-file-path` - Path to the file to upload
+
+**Optional: Github Action Parameters:**
+
+- `slack-upload-filename` - Override the filename shown in Slack
+
+- `slack-upload-file-title` - Title of the file
+
+- `slack-upload-initial-comment` - Initial comment to add alongside the file
+
+**Upload security checks:**
+
+- Uploads are limited to `https` and allowlisted Slack upload hosts only. Currently the upload host must be `files.slack.com`.
+
+- Uploads are limited to 10 MB per file.
+
+- The upload path must point to a file with an allowlisted extension. Current allowlist: `.txt`, `.log`, `.json`, `.yaml`, `.yml`, `.xml`, `.pdf`, `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg`, `.tif`, `.tiff`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`.
+
+### Sample Action file
+
+```
+name: slack-upload-file
+
+on: [push]
+
+jobs:
+ slack-upload-file:
+ runs-on: ubuntu-24.04
+ name: Uploads a file to Slack
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Upload File to Slack
+ uses: step-security/github-actions-slack@v3
+ id: upload-file
+ with:
+ slack-function: upload-file
+ slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }}
+ slack-channel: CPPUV5KU0
+ slack-upload-file-path: path/to/file.png
+ slack-upload-file-title: My File
+ slack-upload-initial-comment: Here is the file!
+ - name: Result from "Upload File"
+ run: echo "${{ steps.upload-file.outputs.slack-result }}"
+```
+
+
+
## How to setup your first Github Action in your repository that will call this Action
### 1. Create a Slack bot
@@ -413,12 +476,12 @@ The source code moved from CommonJS (`require`) to ES Modules (`import`/`export`
## Development and testing
-See package.json for `yarn lint`, `yarn test`, etc.
+See package.json for commands.
Remember to create the dist with `npm run build`.
To run local integration test (from this repository):
```
-env BOT_USER_OAUTH_ACCESS_TOKEN= CHANNEL= node integration-test/end-to-end.js
+env BOT_USER_OAUTH_ACCESS_TOKEN= CHANNEL= node integration-test/end-to-end.js
```
diff --git a/action.yml b/action.yml
index d07082e..72c15a3 100644
--- a/action.yml
+++ b/action.yml
@@ -56,7 +56,19 @@ inputs:
description: "https://api.slack.com/methods/chat.postMessage#arg_username"
required: false
slack-function:
- description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update)
+ description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update) or upload-file (https://api.slack.com/methods/files.upload)
+ required: false
+ slack-upload-file-path:
+ description: "https://api.slack.com/methods/files.upload#arg_file"
+ required: false
+ slack-upload-filename:
+ description: "https://api.slack.com/methods/files.upload#arg_filename"
+ required: false
+ slack-upload-file-title:
+ description: "https://api.slack.com/methods/files.upload#arg_title"
+ required: false
+ slack-upload-initial-comment:
+ description: "https://api.slack.com/methods/files.upload#arg_initial_comment"
required: false
slack-emoji-name:
description: "https://api.slack.com/methods/reactions.add#arg_name"
diff --git a/dist/index.cjs b/dist/index.cjs
index 3363922..491e1b9 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -8875,11 +8875,11 @@ var require_mime_types = __commonJS({
}
return exts[0];
}
- function lookup(path) {
- if (!path || typeof path !== "string") {
+ function lookup(path2) {
+ if (!path2 || typeof path2 !== "string") {
return false;
}
- var extension2 = extname("x." + path).toLowerCase().substr(1);
+ var extension2 = extname("x." + path2).toLowerCase().substr(1);
if (!extension2) {
return false;
}
@@ -9984,11 +9984,11 @@ var require_form_data = __commonJS({
"use strict";
var CombinedStream = require_combined_stream();
var util3 = require("util");
- var path = require("path");
+ var path2 = require("path");
var http3 = require("http");
var https3 = require("https");
var parseUrl2 = require("url").parse;
- var fs4 = require("fs");
+ var fs5 = require("fs");
var Stream = require("stream").Stream;
var crypto3 = require("crypto");
var mime = require_mime_types();
@@ -10055,7 +10055,7 @@ var require_form_data = __commonJS({
if (value.end != void 0 && value.end != Infinity && value.start != void 0) {
callback(null, value.end + 1 - (value.start ? value.start : 0));
} else {
- fs4.stat(value.path, function(err, stat2) {
+ fs5.stat(value.path, function(err, stat2) {
if (err) {
callback(err);
return;
@@ -10112,11 +10112,11 @@ var require_form_data = __commonJS({
FormData3.prototype._getContentDisposition = function(value, options) {
var filename;
if (typeof options.filepath === "string") {
- filename = path.normalize(options.filepath).replace(/\\/g, "/");
+ filename = path2.normalize(options.filepath).replace(/\\/g, "/");
} else if (options.filename || value && (value.name || value.path)) {
- filename = path.basename(options.filename || value && (value.name || value.path));
+ filename = path2.basename(options.filename || value && (value.name || value.path));
} else if (value && value.readable && hasOwn(value, "httpVersion")) {
- filename = path.basename(value.client._httpMessage.path || "");
+ filename = path2.basename(value.client._httpMessage.path || "");
}
if (filename) {
return 'filename="' + filename + '"';
@@ -12615,14 +12615,14 @@ var require_util = __commonJS({
}
const port = url2.port != null ? url2.port : url2.protocol === "https:" ? 443 : 80;
let origin2 = url2.origin != null ? url2.origin : `${url2.protocol || ""}//${url2.hostname || ""}:${port}`;
- let path = url2.path != null ? url2.path : `${url2.pathname || ""}${url2.search || ""}`;
+ let path2 = url2.path != null ? url2.path : `${url2.pathname || ""}${url2.search || ""}`;
if (origin2[origin2.length - 1] === "/") {
origin2 = origin2.slice(0, origin2.length - 1);
}
- if (path && path[0] !== "/") {
- path = `/${path}`;
+ if (path2 && path2[0] !== "/") {
+ path2 = `/${path2}`;
}
- return new URL(`${origin2}${path}`);
+ return new URL(`${origin2}${path2}`);
}
if (!isHttpOrHttpsPrefixed(url2.origin || url2.protocol)) {
throw new InvalidArgumentError("Invalid URL protocol: the URL must start with `http:` or `https:`.");
@@ -13073,39 +13073,39 @@ var require_diagnostics = __commonJS({
});
diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => {
const {
- request: { method, path, origin: origin2 }
+ request: { method, path: path2, origin: origin2 }
} = evt;
- debuglog("sending request to %s %s/%s", method, origin2, path);
+ debuglog("sending request to %s %s/%s", method, origin2, path2);
});
diagnosticsChannel.channel("undici:request:headers").subscribe((evt) => {
const {
- request: { method, path, origin: origin2 },
+ request: { method, path: path2, origin: origin2 },
response: { statusCode }
} = evt;
debuglog(
"received response to %s %s/%s - HTTP %d",
method,
origin2,
- path,
+ path2,
statusCode
);
});
diagnosticsChannel.channel("undici:request:trailers").subscribe((evt) => {
const {
- request: { method, path, origin: origin2 }
+ request: { method, path: path2, origin: origin2 }
} = evt;
- debuglog("trailers received from %s %s/%s", method, origin2, path);
+ debuglog("trailers received from %s %s/%s", method, origin2, path2);
});
diagnosticsChannel.channel("undici:request:error").subscribe((evt) => {
const {
- request: { method, path, origin: origin2 },
+ request: { method, path: path2, origin: origin2 },
error: error2
} = evt;
debuglog(
"request to %s %s/%s errored - %s",
method,
origin2,
- path,
+ path2,
error2.message
);
});
@@ -13154,9 +13154,9 @@ var require_diagnostics = __commonJS({
});
diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => {
const {
- request: { method, path, origin: origin2 }
+ request: { method, path: path2, origin: origin2 }
} = evt;
- debuglog("sending request to %s %s/%s", method, origin2, path);
+ debuglog("sending request to %s %s/%s", method, origin2, path2);
});
}
diagnosticsChannel.channel("undici:websocket:open").subscribe((evt) => {
@@ -13219,7 +13219,7 @@ var require_request = __commonJS({
var kHandler = /* @__PURE__ */ Symbol("handler");
var Request = class {
constructor(origin2, {
- path,
+ path: path2,
method,
body,
headers,
@@ -13234,11 +13234,11 @@ var require_request = __commonJS({
expectContinue,
servername
}, handler) {
- if (typeof path !== "string") {
+ if (typeof path2 !== "string") {
throw new InvalidArgumentError("path must be a string");
- } else if (path[0] !== "/" && !(path.startsWith("http://") || path.startsWith("https://")) && method !== "CONNECT") {
+ } else if (path2[0] !== "/" && !(path2.startsWith("http://") || path2.startsWith("https://")) && method !== "CONNECT") {
throw new InvalidArgumentError("path must be an absolute URL or start with a slash");
- } else if (invalidPathRegex.test(path)) {
+ } else if (invalidPathRegex.test(path2)) {
throw new InvalidArgumentError("invalid request path");
}
if (typeof method !== "string") {
@@ -13304,7 +13304,7 @@ var require_request = __commonJS({
this.completed = false;
this.aborted = false;
this.upgrade = upgrade || null;
- this.path = query ? buildURL2(path, query) : path;
+ this.path = query ? buildURL2(path2, query) : path2;
this.origin = origin2;
this.idempotent = idempotent == null ? method === "HEAD" || method === "GET" : idempotent;
this.blocking = blocking == null ? false : blocking;
@@ -17830,7 +17830,7 @@ var require_client_h1 = __commonJS({
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && method !== "TRACE" && method !== "CONNECT";
}
function writeH1(client, request) {
- const { method, path, host, upgrade, blocking, reset } = request;
+ const { method, path: path2, host, upgrade, blocking, reset } = request;
let { body, headers, contentLength } = request;
const expectsPayload = method === "PUT" || method === "POST" || method === "PATCH" || method === "QUERY" || method === "PROPFIND" || method === "PROPPATCH";
if (util3.isFormDataLike(body)) {
@@ -17896,7 +17896,7 @@ var require_client_h1 = __commonJS({
if (blocking) {
socket[kBlocking] = true;
}
- let header = `${method} ${path} HTTP/1.1\r
+ let header = `${method} ${path2} HTTP/1.1\r
`;
if (typeof host === "string") {
header += `host: ${host}\r
@@ -18422,7 +18422,7 @@ var require_client_h2 = __commonJS({
}
function writeH2(client, request) {
const session = client[kHTTP2Session];
- const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request;
+ const { method, path: path2, host, upgrade, expectContinue, signal, headers: reqHeaders } = request;
let { body } = request;
if (upgrade) {
util3.errorRequest(client, request, new Error("Upgrade not supported for H2"));
@@ -18489,7 +18489,7 @@ var require_client_h2 = __commonJS({
});
return true;
}
- headers[HTTP2_HEADER_PATH] = path;
+ headers[HTTP2_HEADER_PATH] = path2;
headers[HTTP2_HEADER_SCHEME] = "https";
const expectsPayload = method === "PUT" || method === "POST" || method === "PATCH";
if (body && typeof body.read === "function") {
@@ -18842,9 +18842,9 @@ var require_redirect_handler = __commonJS({
return this.handler.onHeaders(statusCode, headers, resume, statusText);
}
const { origin: origin2, pathname, search } = util3.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)));
- const path = search ? `${pathname}${search}` : pathname;
+ const path2 = search ? `${pathname}${search}` : pathname;
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin2);
- this.opts.path = path;
+ this.opts.path = path2;
this.opts.origin = origin2;
this.opts.maxRedirections = 0;
this.opts.query = null;
@@ -20079,10 +20079,10 @@ var require_proxy_agent = __commonJS({
};
const {
origin: origin2,
- path = "/",
+ path: path2 = "/",
headers = {}
} = opts;
- opts.path = origin2 + path;
+ opts.path = origin2 + path2;
if (!("host" in headers) && !("Host" in headers)) {
const { host } = new URL2(origin2);
headers.host = host;
@@ -22003,20 +22003,20 @@ var require_mock_utils = __commonJS({
}
return true;
}
- function safeUrl(path) {
- if (typeof path !== "string") {
- return path;
+ function safeUrl(path2) {
+ if (typeof path2 !== "string") {
+ return path2;
}
- const pathSegments = path.split("?");
+ const pathSegments = path2.split("?");
if (pathSegments.length !== 2) {
- return path;
+ return path2;
}
const qp = new URLSearchParams(pathSegments.pop());
qp.sort();
return [...pathSegments, qp.toString()].join("?");
}
- function matchKey(mockDispatch2, { path, method, body, headers }) {
- const pathMatch = matchValue(mockDispatch2.path, path);
+ function matchKey(mockDispatch2, { path: path2, method, body, headers }) {
+ const pathMatch = matchValue(mockDispatch2.path, path2);
const methodMatch = matchValue(mockDispatch2.method, method);
const bodyMatch = typeof mockDispatch2.body !== "undefined" ? matchValue(mockDispatch2.body, body) : true;
const headersMatch = matchHeaders(mockDispatch2, headers);
@@ -22038,7 +22038,7 @@ var require_mock_utils = __commonJS({
function getMockDispatch(mockDispatches, key) {
const basePath = key.query ? buildURL2(key.path, key.query) : key.path;
const resolvedPath = typeof basePath === "string" ? safeUrl(basePath) : basePath;
- let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath));
+ let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path: path2 }) => matchValue(safeUrl(path2), resolvedPath));
if (matchedMockDispatches.length === 0) {
throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`);
}
@@ -22076,9 +22076,9 @@ var require_mock_utils = __commonJS({
}
}
function buildKey(opts) {
- const { path, method, body, headers, query } = opts;
+ const { path: path2, method, body, headers, query } = opts;
return {
- path,
+ path: path2,
method,
body,
headers,
@@ -22541,10 +22541,10 @@ var require_pending_interceptors_formatter = __commonJS({
}
format(pendingInterceptors) {
const withPrettyHeaders = pendingInterceptors.map(
- ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin: origin2 }) => ({
+ ({ method, path: path2, data: { statusCode }, persist, times, timesInvoked, origin: origin2 }) => ({
Method: method,
Origin: origin2,
- Path: path,
+ Path: path2,
"Status code": statusCode,
Persistent: persist ? PERSISTENT : NOT_PERSISTENT,
Invocations: timesInvoked,
@@ -27425,9 +27425,9 @@ var require_util6 = __commonJS({
}
}
}
- function validateCookiePath(path) {
- for (let i = 0; i < path.length; ++i) {
- const code = path.charCodeAt(i);
+ function validateCookiePath(path2) {
+ for (let i = 0; i < path2.length; ++i) {
+ const code = path2.charCodeAt(i);
if (code < 32 || // exclude CTLs (0-31)
code === 127 || // DEL
code === 59) {
@@ -30104,11 +30104,11 @@ var require_undici = __commonJS({
if (typeof opts.path !== "string") {
throw new InvalidArgumentError("invalid opts.path");
}
- let path = opts.path;
+ let path2 = opts.path;
if (!opts.path.startsWith("/")) {
- path = `/${path}`;
+ path2 = `/${path2}`;
}
- url2 = new URL(util3.parseOrigin(url2).origin + path);
+ url2 = new URL(util3.parseOrigin(url2).origin + path2);
} else {
if (!opts) {
opts = typeof url2 === "object" ? url2 : {};
@@ -30699,9 +30699,9 @@ function isVisitable(thing) {
function removeBrackets(key) {
return utils_default.endsWith(key, "[]") ? key.slice(0, -2) : key;
}
-function renderKey(path, key, dots) {
- if (!path) return key;
- return path.concat(key).map(function each(token, i) {
+function renderKey(path2, key, dots) {
+ if (!path2) return key;
+ return path2.concat(key).map(function each(token, i) {
token = removeBrackets(token);
return !dots && i ? "[" + token + "]" : token;
}).join(dots ? "." : "");
@@ -30754,13 +30754,13 @@ function toFormData(obj, formData, options) {
}
return value;
}
- function defaultVisitor(value, key, path) {
+ function defaultVisitor(value, key, path2) {
let arr = value;
if (utils_default.isReactNative(formData) && utils_default.isReactNativeBlob(value)) {
- formData.append(renderKey(path, key, dots), convertValue(value));
+ formData.append(renderKey(path2, key, dots), convertValue(value));
return false;
}
- if (value && !path && typeof value === "object") {
+ if (value && !path2 && typeof value === "object") {
if (utils_default.endsWith(key, "{}")) {
key = metaTokens ? key : key.slice(0, -2);
value = JSON.stringify(value);
@@ -30779,7 +30779,7 @@ function toFormData(obj, formData, options) {
if (isVisitable(value)) {
return true;
}
- formData.append(renderKey(path, key, dots), convertValue(value));
+ formData.append(renderKey(path2, key, dots), convertValue(value));
return false;
}
const stack = [];
@@ -30788,16 +30788,16 @@ function toFormData(obj, formData, options) {
convertValue,
isVisitable
});
- function build(value, path) {
+ function build(value, path2) {
if (utils_default.isUndefined(value)) return;
if (stack.indexOf(value) !== -1) {
- throw Error("Circular reference detected in " + path.join("."));
+ throw Error("Circular reference detected in " + path2.join("."));
}
stack.push(value);
utils_default.forEach(value, function each(el, key) {
- const result = !(utils_default.isUndefined(el) || el === null) && visitor.call(formData, el, utils_default.isString(key) ? key.trim() : key, path, exposedHelpers);
+ const result = !(utils_default.isUndefined(el) || el === null) && visitor.call(formData, el, utils_default.isString(key) ? key.trim() : key, path2, exposedHelpers);
if (result === true) {
- build(el, path ? path.concat(key) : [key]);
+ build(el, path2 ? path2.concat(key) : [key]);
}
});
stack.pop();
@@ -31009,7 +31009,7 @@ var platform_default = {
// node_modules/axios/lib/helpers/toURLEncodedForm.js
function toURLEncodedForm(data, options) {
return toFormData_default(data, new platform_default.classes.URLSearchParams(), {
- visitor: function(value, key, path, helpers) {
+ visitor: function(value, key, path2, helpers) {
if (platform_default.isNode && utils_default.isBuffer(value)) {
this.append(key, value.toString("base64"));
return false;
@@ -31039,11 +31039,11 @@ function arrayToObject(arr) {
return obj;
}
function formDataToJSON(formData) {
- function buildPath(path, value, target, index) {
- let name = path[index++];
+ function buildPath(path2, value, target, index) {
+ let name = path2[index++];
if (name === "__proto__") return true;
const isNumericKey = Number.isFinite(+name);
- const isLast = index >= path.length;
+ const isLast = index >= path2.length;
name = !name && utils_default.isArray(target) ? target.length : name;
if (isLast) {
if (utils_default.hasOwnProp(target, name)) {
@@ -31056,7 +31056,7 @@ function formDataToJSON(formData) {
if (!target[name] || !utils_default.isObject(target[name])) {
target[name] = [];
}
- const result = buildPath(path, value, target[name], index);
+ const result = buildPath(path2, value, target[name], index);
if (result && utils_default.isArray(target[name])) {
target[name] = arrayToObject(target[name]);
}
@@ -32506,9 +32506,9 @@ var http_default = isHttpAdapterSupported && function httpAdapter(config) {
auth = urlUsername + ":" + urlPassword;
}
auth && headers.delete("authorization");
- let path;
+ let path2;
try {
- path = buildURL(
+ path2 = buildURL(
parsed.pathname + parsed.search,
config.params,
config.paramsSerializer
@@ -32526,7 +32526,7 @@ var http_default = isHttpAdapterSupported && function httpAdapter(config) {
false
);
const options = {
- path,
+ path: path2,
method,
headers: headers.toJSON(),
agents: { http: config.httpAgent, https: config.httpsAgent },
@@ -32775,14 +32775,14 @@ var isURLSameOrigin_default = platform_default.hasStandardBrowserEnv ? /* @__PUR
var cookies_default = platform_default.hasStandardBrowserEnv ? (
// Standard browser envs support document.cookie
{
- write(name, value, expires, path, domain, secure, sameSite) {
+ write(name, value, expires, path2, domain, secure, sameSite) {
if (typeof document === "undefined") return;
const cookie = [`${name}=${encodeURIComponent(value)}`];
if (utils_default.isNumber(expires)) {
cookie.push(`expires=${new Date(expires).toUTCString()}`);
}
- if (utils_default.isString(path)) {
- cookie.push(`path=${path}`);
+ if (utils_default.isString(path2)) {
+ cookie.push(`path=${path2}`);
}
if (utils_default.isString(domain)) {
cookie.push(`domain=${domain}`);
@@ -34028,7 +34028,7 @@ var {
} = axios_default;
// src/invoke.js
-var import_fs2 = __toESM(require("fs"), 1);
+var import_fs3 = __toESM(require("fs"), 1);
// node_modules/@actions/core/lib/command.js
var os = __toESM(require("os"), 1);
@@ -34538,13 +34538,18 @@ var debugExtra = (name, json) => {
debug(message);
};
+// src/integration/slack-api.js
+var import_fs2 = __toESM(require("fs"), 1);
+var import_path = __toESM(require("path"), 1);
+
// src/integration/slack-api-post.js
var import_https2 = __toESM(require("https"), 1);
-var getOptions = (token, path) => {
+var ALLOWED_UPLOAD_HOSTS = /* @__PURE__ */ new Set(["files.slack.com"]);
+var getOptions = (token, path2) => {
return {
hostname: "slack.com",
port: 443,
- path,
+ path: path2,
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
@@ -34552,11 +34557,11 @@ var getOptions = (token, path) => {
}
};
};
-var post = (token, path, message) => {
+var post = (token, path2, message) => {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(message);
debugExtra("SLACK POST PAYLOAD", payload);
- const options = getOptions(token, path);
+ const options = getOptions(token, path2);
const req = import_https2.default.request(options, (res) => {
const chunks = [];
res.on("data", (chunk) => {
@@ -34585,36 +34590,193 @@ var post = (token, path, message) => {
req.end();
});
};
+var postForm = (token, path2, fields) => {
+ return new Promise((resolve, reject) => {
+ const payload = new URLSearchParams(fields).toString();
+ debugExtra("SLACK POST FORM PAYLOAD", payload);
+ const options = {
+ hostname: "slack.com",
+ port: 443,
+ path: path2,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Content-Length": Buffer.byteLength(payload),
+ Authorization: `Bearer ${token}`
+ }
+ };
+ const req = import_https2.default.request(options, (res) => {
+ const chunks = [];
+ res.on("data", (chunk) => {
+ chunks.push(chunk);
+ });
+ res.on("end", () => {
+ const result = Buffer.concat(chunks).toString();
+ const response = JSON.parse(result);
+ let isOk = res.statusCode >= 200 && res.statusCode <= 299;
+ if (response && response.hasOwnProperty("ok") && response.ok === false) {
+ isOk = false;
+ }
+ resolve({
+ statusCode: res.statusCode,
+ statusMessage: res.statusMessage,
+ ok: isOk,
+ result,
+ response
+ });
+ });
+ });
+ req.on("error", (error2) => {
+ reject(error2);
+ });
+ req.write(payload);
+ req.end();
+ });
+};
+var validateUploadUrl = (uploadUrl) => {
+ const url2 = new URL(uploadUrl);
+ if (url2.protocol !== "https:") {
+ throw new Error("Upload URL must use https");
+ }
+ if (!ALLOWED_UPLOAD_HOSTS.has(url2.hostname)) {
+ throw new Error(`Upload host not allowed: ${url2.hostname}`);
+ }
+ return url2;
+};
+var postBinary = (uploadUrl, fileContent) => {
+ return new Promise((resolve, reject) => {
+ const url2 = validateUploadUrl(uploadUrl);
+ const options = {
+ hostname: url2.hostname,
+ port: 443,
+ path: url2.pathname + url2.search,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Length": fileContent.length
+ }
+ };
+ debug2("SLACK UPLOAD BINARY to " + url2.hostname + url2.pathname);
+ const req = import_https2.default.request(options, (res) => {
+ const chunks = [];
+ res.on("data", (chunk) => {
+ chunks.push(chunk);
+ });
+ res.on("end", () => {
+ resolve({
+ statusCode: res.statusCode,
+ ok: res.statusCode >= 200 && res.statusCode <= 299
+ });
+ });
+ });
+ req.on("error", (error2) => {
+ reject(error2);
+ });
+ req.write(fileContent);
+ req.end();
+ });
+};
// src/integration/slack-api.js
+var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([
+ ".txt",
+ ".log",
+ ".json",
+ ".yaml",
+ ".yml",
+ ".xml",
+ ".pdf",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".webp",
+ ".bmp",
+ ".svg",
+ ".tif",
+ ".tiff",
+ ".doc",
+ ".docx",
+ ".xls",
+ ".xlsx",
+ ".ppt",
+ ".pptx"
+]);
var hasErrors = (res) => !res || !res.ok;
var buildErrorMessage = (res) => {
return `Error! ${JSON.stringify(res)} (response)`;
};
+var validateUploadFile = (filePath) => {
+ const extension = import_path.default.extname(filePath).toLowerCase();
+ if (!ALLOWED_EXTENSIONS.has(extension)) {
+ throw new Error(`File type not allowed: ${extension || ""}`);
+ }
+ const stat2 = import_fs2.default.statSync(filePath);
+ if (!stat2.isFile()) {
+ throw new Error("Upload path must be a file");
+ }
+ if (stat2.size > MAX_UPLOAD_BYTES) {
+ throw new Error(`File too large: ${stat2.size}`);
+ }
+};
var apiPostMessage = async (token, message) => {
- const path = "/api/chat.postMessage";
- const res = await post(token, path, message);
+ const path2 = "/api/chat.postMessage";
+ const res = await post(token, path2, message);
if (hasErrors(res)) {
throw buildErrorMessage(res);
}
return res;
};
var apiAddReaction = async (token, message) => {
- const path = "/api/reactions.add";
- const res = await post(token, path, message);
+ const path2 = "/api/reactions.add";
+ const res = await post(token, path2, message);
if (hasErrors(res)) {
throw buildErrorMessage(res);
}
return res;
};
var apiUpdateMessage = async (token, message) => {
- const path = "/api/chat.update";
- const res = await post(token, path, message);
+ const path2 = "/api/chat.update";
+ const res = await post(token, path2, message);
if (hasErrors(res)) {
throw buildErrorMessage(res);
}
return res;
};
+var apiUploadFile = async (token, payload) => {
+ validateUploadFile(payload.filePath);
+ const fileContent = import_fs2.default.readFileSync(payload.filePath);
+ const filename = payload.filename || import_path.default.basename(payload.filePath);
+ const urlRes = await postForm(token, "/api/files.getUploadURLExternal", {
+ filename,
+ length: fileContent.length
+ });
+ if (hasErrors(urlRes)) {
+ throw buildErrorMessage(urlRes);
+ }
+ const { upload_url, file_id } = urlRes.response;
+ const uploadRes = await postBinary(upload_url, fileContent);
+ if (!uploadRes.ok) {
+ throw `Error uploading file content (status ${uploadRes.statusCode})`;
+ }
+ const completePayload = {
+ files: [{ id: file_id, title: payload.title || filename }],
+ channel_id: payload.channel
+ };
+ if (payload.initialComment) {
+ completePayload.initial_comment = payload.initialComment;
+ }
+ const result = await post(
+ token,
+ "/api/files.completeUploadExternal",
+ completePayload
+ );
+ if (hasErrors(result)) {
+ throw buildErrorMessage(result);
+ }
+ return result;
+};
// src/util/escaper.js
var restoreEscapedNewLine = (text) => text.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n");
@@ -34768,13 +34930,58 @@ var updateMessage = async () => {
}
};
-// src/invoke.js
+// src/upload-file/build-upload-file.js
+var buildUploadFile = (channel = "", filePath = "", filename = "", title = "", initialComment = "") => {
+ if (!channel) {
+ throw new Error("Channel must be set");
+ }
+ if (!filePath) {
+ throw new Error("File path must be set");
+ }
+ return {
+ channel,
+ filePath,
+ filename,
+ title,
+ initialComment
+ };
+};
+var build_upload_file_default = buildUploadFile;
+
+// src/upload-file/index.js
var jsonPretty4 = (data) => JSON.stringify(data, void 0, 2);
+var uploadFile = async () => {
+ try {
+ const token = getRequired("slack-bot-user-oauth-access-token");
+ const channel = getRequired("slack-channel");
+ const filePath = getRequired("slack-upload-file-path");
+ const filename = getOptional("slack-upload-filename");
+ const title = getOptional("slack-upload-file-title");
+ const initialComment = getOptional("slack-upload-initial-comment");
+ const payload = build_upload_file_default(
+ channel,
+ filePath,
+ filename,
+ title,
+ initialComment
+ );
+ debugExtra("Upload File PAYLOAD", payload);
+ const result = await apiUploadFile(token, payload);
+ debugExtra("Upload File RESULT", result);
+ const resultAsJson = jsonPretty4(result);
+ setOutput2("slack-result", resultAsJson);
+ } catch (error2) {
+ setFailed2(jsonPretty4(error2));
+ }
+};
+
+// src/invoke.js
+var jsonPretty5 = (data) => JSON.stringify(data, void 0, 2);
async function validateSubscription() {
let repoPrivate;
const eventPath = process.env.GITHUB_EVENT_PATH;
- if (eventPath && import_fs2.default.existsSync(eventPath)) {
- const payload = JSON.parse(import_fs2.default.readFileSync(eventPath, "utf8"));
+ if (eventPath && import_fs3.default.existsSync(eventPath)) {
+ const payload = JSON.parse(import_fs3.default.readFileSync(eventPath, "utf8"));
repoPrivate = payload?.repository?.private;
}
const upstream = "archive/github-actions-slack";
@@ -34824,12 +35031,15 @@ var invoke = async () => {
case "update-message":
await updateMessage();
break;
+ case "upload-file":
+ await uploadFile();
+ break;
default:
setFailed2("Unhandled `slack-function`: " + func);
break;
}
} catch (error2) {
- setFailed2("invoke failed:" + error2 + ":" + jsonPretty4(error2));
+ setFailed2("invoke failed:" + error2 + ":" + jsonPretty5(error2));
}
};
var invoke_default = invoke;
diff --git a/images/upload.png b/images/upload.png
new file mode 100644
index 0000000..2232bbd
Binary files /dev/null and b/images/upload.png differ
diff --git a/integration-test/end-to-end.js b/integration-test/end-to-end.js
index 8ab2471..ea10c33 100644
--- a/integration-test/end-to-end.js
+++ b/integration-test/end-to-end.js
@@ -4,6 +4,7 @@ import {
apiPostMessage,
apiAddReaction,
apiUpdateMessage,
+ apiUploadFile,
} from "../src/integration/slack-api.js";
import buildMessage from "../src/message/build-message.js";
import buildReaction from "../src/reaction/build-reaction.js";
@@ -103,4 +104,16 @@ const testUpdateMessage = async (channel, token) => {
process.env.CHANNEL,
process.env.BOT_USER_OAUTH_ACCESS_TOKEN
);
+
+ const uploadResult = await apiUploadFile(
+ process.env.BOT_USER_OAUTH_ACCESS_TOKEN,
+ {
+ channel: process.env.CHANNEL,
+ filePath: "integration-test/one-does-not-simply.jpg",
+ filename: "one-does-not-simply.jpg",
+ title: "Test 5 - Upload File",
+ initialComment: "Test 5 - testUploadFile 📎",
+ }
+ );
+ assert.strictEqual(uploadResult.statusCode, 200);
})();
diff --git a/integration-test/one-does-not-simply.jpg b/integration-test/one-does-not-simply.jpg
new file mode 100644
index 0000000..300e1d2
Binary files /dev/null and b/integration-test/one-does-not-simply.jpg differ
diff --git a/src/integration/slack-api-post.js b/src/integration/slack-api-post.js
index 82d92a0..7a20878 100644
--- a/src/integration/slack-api-post.js
+++ b/src/integration/slack-api-post.js
@@ -1,6 +1,8 @@
import https from "https";
import * as context from "../context.js";
+const ALLOWED_UPLOAD_HOSTS = new Set(["files.slack.com"]);
+
const getOptions = (token, path) => {
return {
hostname: "slack.com",
@@ -64,4 +66,115 @@ const post = (token, path, message) => {
});
};
-export { post };
+// Used for Slack methods that require application/x-www-form-urlencoded (e.g. files.getUploadURLExternal)
+const postForm = (token, path, fields) => {
+ return new Promise((resolve, reject) => {
+ const payload = new URLSearchParams(fields).toString();
+
+ context.debugExtra("SLACK POST FORM PAYLOAD", payload);
+
+ const options = {
+ hostname: "slack.com",
+ port: 443,
+ path: path,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Content-Length": Buffer.byteLength(payload),
+ Authorization: `Bearer ${token}`,
+ },
+ };
+
+ const req = https.request(options, (res) => {
+ const chunks = [];
+
+ res.on("data", (chunk) => {
+ chunks.push(chunk);
+ });
+
+ res.on("end", () => {
+ const result = Buffer.concat(chunks).toString();
+ const response = JSON.parse(result);
+
+ let isOk = res.statusCode >= 200 && res.statusCode <= 299;
+
+ if (response && response.hasOwnProperty("ok") && response.ok === false) {
+ isOk = false;
+ }
+
+ resolve({
+ statusCode: res.statusCode,
+ statusMessage: res.statusMessage,
+ ok: isOk,
+ result: result,
+ response: response,
+ });
+ });
+ });
+
+ req.on("error", (error) => {
+ reject(error);
+ });
+
+ req.write(payload);
+ req.end();
+ });
+};
+
+const validateUploadUrl = (uploadUrl) => {
+ const url = new URL(uploadUrl);
+
+ if (url.protocol !== "https:") {
+ throw new Error("Upload URL must use https");
+ }
+
+ if (!ALLOWED_UPLOAD_HOSTS.has(url.hostname)) {
+ throw new Error(`Upload host not allowed: ${url.hostname}`);
+ }
+
+ return url;
+};
+
+// Used for step 2 of the files.getUploadURLExternal flow — posts raw binary
+// to the pre-signed upload URL returned by Slack (may be a different hostname).
+const postBinary = (uploadUrl, fileContent) => {
+ return new Promise((resolve, reject) => {
+ const url = validateUploadUrl(uploadUrl);
+ const options = {
+ hostname: url.hostname,
+ port: 443,
+ path: url.pathname + url.search,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Length": fileContent.length,
+ },
+ };
+
+ context.debug("SLACK UPLOAD BINARY to " + url.hostname + url.pathname);
+
+ const req = https.request(options, (res) => {
+ const chunks = [];
+
+ res.on("data", (chunk) => {
+ chunks.push(chunk);
+ });
+
+ res.on("end", () => {
+ resolve({
+ statusCode: res.statusCode,
+ ok: res.statusCode >= 200 && res.statusCode <= 299,
+ });
+ });
+ });
+
+ req.on("error", (error) => {
+ reject(error);
+ });
+
+ req.write(fileContent);
+ req.end();
+ });
+};
+
+export { post, postForm, postBinary, validateUploadUrl, ALLOWED_UPLOAD_HOSTS };
diff --git a/src/integration/slack-api.js b/src/integration/slack-api.js
index 46872c9..80b7c0b 100644
--- a/src/integration/slack-api.js
+++ b/src/integration/slack-api.js
@@ -1,4 +1,33 @@
-import { post } from "./slack-api-post.js";
+import fs from "fs";
+import path from "path";
+import { post, postForm, postBinary } from "./slack-api-post.js";
+
+// Keep uploads small and predictable to reduce accidental data exfiltration.
+const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+const ALLOWED_EXTENSIONS = new Set([
+ ".txt",
+ ".log",
+ ".json",
+ ".yaml",
+ ".yml",
+ ".xml",
+ ".pdf",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".webp",
+ ".bmp",
+ ".svg",
+ ".tif",
+ ".tiff",
+ ".doc",
+ ".docx",
+ ".xls",
+ ".xlsx",
+ ".ppt",
+ ".pptx",
+]);
const hasErrors = (res) => !res || !res.ok;
@@ -6,6 +35,24 @@ const buildErrorMessage = (res) => {
return `Error! ${JSON.stringify(res)} (response)`;
};
+const validateUploadFile = (filePath) => {
+ const extension = path.extname(filePath).toLowerCase();
+
+ if (!ALLOWED_EXTENSIONS.has(extension)) {
+ throw new Error(`File type not allowed: ${extension || ""}`);
+ }
+
+ const stat = fs.statSync(filePath);
+
+ if (!stat.isFile()) {
+ throw new Error("Upload path must be a file");
+ }
+
+ if (stat.size > MAX_UPLOAD_BYTES) {
+ throw new Error(`File too large: ${stat.size}`);
+ }
+};
+
const apiPostMessage = async (token, message) => {
const path = "/api/chat.postMessage";
const res = await post(token, path, message);
@@ -39,4 +86,55 @@ const apiUpdateMessage = async (token, message) => {
return res;
};
-export { apiPostMessage, apiAddReaction, apiUpdateMessage };
+const apiUploadFile = async (token, payload) => {
+ validateUploadFile(payload.filePath);
+ const fileContent = fs.readFileSync(payload.filePath);
+ const filename = payload.filename || path.basename(payload.filePath);
+
+ // Step 1: Get upload URL and file ID (requires form encoding, not JSON)
+ const urlRes = await postForm(token, "/api/files.getUploadURLExternal", {
+ filename,
+ length: fileContent.length,
+ });
+ if (hasErrors(urlRes)) {
+ throw buildErrorMessage(urlRes);
+ }
+
+ const { upload_url, file_id } = urlRes.response;
+
+ // Step 2: Upload the file binary to the pre-signed URL
+ const uploadRes = await postBinary(upload_url, fileContent);
+ if (!uploadRes.ok) {
+ throw `Error uploading file content (status ${uploadRes.statusCode})`;
+ }
+
+ // Step 3: Complete the upload and share to channel
+ const completePayload = {
+ files: [{ id: file_id, title: payload.title || filename }],
+ channel_id: payload.channel,
+ };
+ if (payload.initialComment) {
+ completePayload.initial_comment = payload.initialComment;
+ }
+
+ const result = await post(
+ token,
+ "/api/files.completeUploadExternal",
+ completePayload
+ );
+ if (hasErrors(result)) {
+ throw buildErrorMessage(result);
+ }
+
+ return result;
+};
+
+export {
+ apiPostMessage,
+ apiAddReaction,
+ apiUpdateMessage,
+ apiUploadFile,
+ validateUploadFile,
+ ALLOWED_EXTENSIONS,
+ MAX_UPLOAD_BYTES,
+};
diff --git a/src/integration/slack-api.test.js b/src/integration/slack-api.test.js
index ceb6e4b..7ef8efd 100644
--- a/src/integration/slack-api.test.js
+++ b/src/integration/slack-api.test.js
@@ -1,6 +1,12 @@
import { jest } from "@jest/globals";
+import fs from "fs";
describe("slack api", () => {
+ afterEach(() => {
+ jest.resetModules();
+ jest.restoreAllMocks();
+ });
+
test("fail when slack api wrapper returns ok is false", async () => {
expect.assertions(1);
@@ -12,6 +18,12 @@ describe("slack api", () => {
post: function (token, path, message) {
return errorResponse;
},
+ postForm: function (token, path, fields) {
+ return errorResponse;
+ },
+ postBinary: function (uploadUrl, fileContent) {
+ return errorResponse;
+ },
}));
const slackApi = await import("./slack-api.js");
@@ -21,4 +33,42 @@ describe("slack api", () => {
expect(error).toContain("Error!");
}
});
+
+ test("reject upload when host is not allowlisted", async () => {
+ expect.assertions(1);
+
+ jest.unstable_unmockModule("./slack-api-post.js");
+ const slackApiPost = await import("./slack-api-post.js");
+
+ expect(() =>
+ slackApiPost.validateUploadUrl("https://example.com/upload")
+ ).toThrow("Upload host not allowed: example.com");
+ });
+
+ test("reject upload when extension is not allowlisted", async () => {
+ expect.assertions(1);
+
+ jest.unstable_unmockModule("./slack-api-post.js");
+ const slackApi = await import("./slack-api.js");
+
+ expect(() => slackApi.validateUploadFile("C:\\temp\\secret.exe")).toThrow(
+ "File type not allowed: .exe"
+ );
+ });
+
+ test("reject upload when file exceeds size limit", async () => {
+ expect.assertions(1);
+
+ jest.spyOn(fs, "statSync").mockReturnValue({
+ isFile: () => true,
+ size: 10 * 1024 * 1024 + 1,
+ });
+
+ jest.unstable_unmockModule("./slack-api-post.js");
+ const slackApi = await import("./slack-api.js");
+
+ expect(() =>
+ slackApi.validateUploadFile("C:\\temp\\report.txt")
+ ).toThrow("File too large");
+ });
});
diff --git a/src/invoke.js b/src/invoke.js
index 0048c6d..91c0d3c 100644
--- a/src/invoke.js
+++ b/src/invoke.js
@@ -5,6 +5,7 @@ import * as context from "./context.js";
import { postMessage } from "./message/index.js";
import { addReaction } from "./reaction/index.js";
import { updateMessage } from "./update-message/index.js";
+import { uploadFile } from "./upload-file/index.js";
const jsonPretty = (data) => JSON.stringify(data, undefined, 2);
@@ -70,6 +71,9 @@ const invoke = async () => {
case "update-message":
await updateMessage();
break;
+ case "upload-file":
+ await uploadFile();
+ break;
default:
context.setFailed("Unhandled `slack-function`: " + func);
break;
diff --git a/src/upload-file/build-upload-file.js b/src/upload-file/build-upload-file.js
new file mode 100644
index 0000000..60c7e4e
--- /dev/null
+++ b/src/upload-file/build-upload-file.js
@@ -0,0 +1,25 @@
+const buildUploadFile = (
+ channel = "",
+ filePath = "",
+ filename = "",
+ title = "",
+ initialComment = ""
+) => {
+ if (!channel) {
+ throw new Error("Channel must be set");
+ }
+
+ if (!filePath) {
+ throw new Error("File path must be set");
+ }
+
+ return {
+ channel,
+ filePath,
+ filename,
+ title,
+ initialComment,
+ };
+};
+
+export default buildUploadFile;
diff --git a/src/upload-file/index.js b/src/upload-file/index.js
new file mode 100644
index 0000000..d510b51
--- /dev/null
+++ b/src/upload-file/index.js
@@ -0,0 +1,35 @@
+import * as context from "../context.js";
+import { apiUploadFile } from "../integration/slack-api.js";
+import buildUploadFile from "./build-upload-file.js";
+
+const jsonPretty = (data) => JSON.stringify(data, undefined, 2);
+
+const uploadFile = async () => {
+ try {
+ const token = context.getRequired("slack-bot-user-oauth-access-token");
+ const channel = context.getRequired("slack-channel");
+ const filePath = context.getRequired("slack-upload-file-path");
+ const filename = context.getOptional("slack-upload-filename");
+ const title = context.getOptional("slack-upload-file-title");
+ const initialComment = context.getOptional("slack-upload-initial-comment");
+
+ const payload = buildUploadFile(
+ channel,
+ filePath,
+ filename,
+ title,
+ initialComment
+ );
+
+ context.debugExtra("Upload File PAYLOAD", payload);
+ const result = await apiUploadFile(token, payload);
+ context.debugExtra("Upload File RESULT", result);
+
+ const resultAsJson = jsonPretty(result);
+ context.setOutput("slack-result", resultAsJson);
+ } catch (error) {
+ context.setFailed(jsonPretty(error));
+ }
+};
+
+export { uploadFile };