This commit is contained in:
24c02 2026-01-30 15:11:21 -05:00
parent 64a833329c
commit 83a57cbe9e
8 changed files with 332 additions and 0 deletions

View file

@ -71,3 +71,5 @@ gem "blind_index"
gem "sentry-ruby"
gem "sentry-rails"
gem "aws-sdk-s3", require: false
gem "slack-ruby-client"
gem "slocks", git: "https://github.com/24c02/slocks"

View file

@ -1,3 +1,11 @@
GIT
remote: https://github.com/24c02/slocks
revision: 08f4b98858b30e52ea212ae4ebdca1cea7978b93
specs:
slocks (0.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
GEM
remote: https://rubygems.org/
specs:
@ -140,12 +148,19 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-mashify (1.0.2)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
fiddle (1.1.8)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
gli (2.22.2)
ostruct
globalid (1.3.0)
activesupport (>= 6.1)
hashid-rails (1.4.1)
@ -201,6 +216,7 @@ GEM
msgpack (1.8.0)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
@ -249,6 +265,7 @@ GEM
omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.1)
ast (~> 2.4.1)
@ -400,6 +417,13 @@ GEM
sentry-ruby (6.3.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
slack-ruby-client (3.1.0)
faraday (>= 2.0.1)
faraday-mashify
faraday-multipart
gli
hashie
logger
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
@ -506,6 +530,8 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
slack-ruby-client
slocks!
solid_cable
solid_cache
solid_queue

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
module Slack
class EventsController < ActionController::API
skip_before_action :verify_authenticity_token
before_action :verify_slack_signature
def create
# Handle Slack URL verification challenge
if params[:type] == "url_verification"
return render json: { challenge: params[:challenge] }
end
# Handle event callbacks
if params[:type] == "event_callback"
event = params[:event]
# Filter to message events with files in monitored channels
if event[:type] == "message" && event[:files].present? && monitored_channel?(event[:channel])
ProcessSlackFileUploadJob.perform_later(event.to_unsafe_h)
end
end
# Respond immediately (Slack requires < 3s response)
head :ok
end
private
def verify_slack_signature
timestamp = request.headers["X-Slack-Request-Timestamp"]
signature = request.headers["X-Slack-Signature"]
body = request.raw_post
unless SlackService.verify_signature(timestamp, body, signature)
render json: { error: "Invalid signature" }, status: :unauthorized
end
end
def monitored_channel?(channel_id)
Rails.application.config.slack.cdn_channels.include?(channel_id)
end
end
end

View file

@ -0,0 +1,192 @@
# frozen_string_literal: true
class ProcessSlackFileUploadJob < ApplicationJob
queue_as :default
class QuotaExceededError < StandardError; end
def perform(event)
channel_id = event["channel"]
message_ts = event["ts"]
slack_user_id = event["user"]
files = event["files"]
return unless files.present?
slack_service = SlackService.new
bot_token = Rails.application.config.slack.bot_token
# Find or create user
user = find_or_create_user(slack_user_id, slack_service)
# Add beachball reaction
slack_service.add_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "beach_ball"
)
# Send initial funny flavor message
flavor_message = pick_flavor_message(files)
slack_service.reply_in_thread(
channel: channel_id,
thread_ts: message_ts,
text: flavor_message
)
begin
uploads = []
# Process each file
files.each do |file|
original_url = file["url_private"]
# Create upload with Slack authorization
upload = Upload.create_from_url(
original_url,
user: user,
provenance: :slack,
original_url: original_url,
authorization: "Bearer #{bot_token}"
)
# Check quota AFTER upload (size unknown beforehand)
quota_service = QuotaService.new(user)
if user.total_storage_bytes > quota_service.current_policy.max_total_storage
upload.destroy!
raise QuotaExceededError, "Storage quota exceeded"
end
uploads << upload
end
# Success: remove beachball, add checkmark, reply with Block Kit message
slack_service.remove_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "beach_ball"
)
slack_service.add_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "white_check_mark"
)
# Build Block Kit message using Slocks template
blocks_json = ApplicationController.render(
template: "slack/upload_success",
formats: [:slack_message],
locals: {
uploads: uploads,
slack_user_id: slack_user_id
}
)
slack_service.reply_in_thread(
channel: channel_id,
thread_ts: message_ts,
text: "Yeah! Here's yo' links", # Fallback for notifications
blocks: JSON.parse(blocks_json)
)
rescue QuotaExceededError => e
# Quota exceeded: remove beachball, add X, reply with error
slack_service.remove_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "beach_ball"
)
slack_service.add_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "x"
)
slack_service.reply_in_thread(
channel: channel_id,
thread_ts: message_ts,
text: "Storage quota exceeded - verify your account at cdn.hackclub.com"
)
rescue => e
# General error: remove beachball, add X, reply with funny error
Rails.logger.error "Slack file upload failed: #{e.message}\n#{e.backtrace.join("\n")}"
begin
slack_service.remove_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "beach_ball"
)
slack_service.add_reaction(
channel: channel_id,
timestamp: message_ts,
emoji: "x"
)
error_message = pick_error_message
slack_service.reply_in_thread(
channel: channel_id,
thread_ts: message_ts,
text: error_message
)
rescue => slack_error
Rails.logger.error "Failed to send Slack error notification: #{slack_error.message}"
end
end
end
private
def find_or_create_user(slack_user_id, slack_service)
# First check if user exists
user = User.find_by(slack_id: slack_user_id)
unless user
# Fetch profile from Slack API
profile = slack_service.fetch_user_profile(slack_user_id)
user = User.create!(
slack_id: slack_user_id,
email: profile[:profile][:email] || "slack-#{slack_user_id}@temp.hackclub.com",
name: profile[:real_name] || profile[:name] || "Slack User"
)
end
user
end
def pick_flavor_message(files)
# Collect all possible flavor messages based on file extensions
flavor_messages = ["thanks, i'm gonna sell these to adfly!"] # generic fallback
files.each do |file|
ext = File.extname(file["name"]).delete_prefix(".").downcase
case ext
when "gif"
flavor_messages += ["_gif_ that file to me and i'll upload it", "_gif_ me all all your files!"]
when "heic"
flavor_messages << "What the heic???"
when "mov"
flavor_messages << "I'll _mov_ that to a permanent link for you"
when "html"
flavor_messages += ["Oh, launching a new website?", "uwu, what's this site?", "WooOOAAah hey! Are you serving a site?", "h-t-m-ello :wave:"]
when "rar"
flavor_messages += [".rawr xD", "i also go \"rar\" sometimes!"]
end
end
flavor_messages.sample
end
def pick_error_message
[
"_cdnpheus sneezes and drops the files on the ground before blowing her nose on a blank jpeg._",
"_cdnpheus trips and your files slip out of her hands and into an inconveniently placed sewer grate._",
"_cdnpheus accidentally slips the files into a folder in her briefcase labeled \"homework\". she starts sweating profusely._"
].sample
end
end

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
class SlackService
def initialize(bot_token = nil)
@client = Slack::Web::Client.new(token: bot_token || Rails.application.config.slack.bot_token)
end
def add_reaction(channel:, timestamp:, emoji:)
@client.reactions_add(
channel: channel,
timestamp: timestamp,
name: emoji
)
end
def remove_reaction(channel:, timestamp:, emoji:)
@client.reactions_remove(
channel: channel,
timestamp: timestamp,
name: emoji
)
end
def reply_in_thread(channel:, thread_ts:, text:, blocks: nil)
@client.chat_postMessage(
channel: channel,
thread_ts: thread_ts,
text: text,
blocks: blocks
)
end
def fetch_user_profile(user_id)
response = @client.users_info(user: user_id)
response.user
end
def self.verify_signature(timestamp, body, signature)
signing_secret = Rails.application.config.slack.signing_secret
return false if signing_secret.blank?
# Check timestamp to prevent replay attacks (within 5 minutes)
return false if (Time.now.to_i - timestamp.to_i).abs > 300
# Compute expected signature
sig_basestring = "v0:#{timestamp}:#{body}"
expected_signature = "v0=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, sig_basestring)
# Constant-time comparison
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
end
end

View file

@ -0,0 +1,7 @@
section "Yeah! Here's yo' links <@#{slack_user_id}>", markdown: true
uploads.each do |upload|
section "<#{upload.cdn_url}|#{upload.filename}>", markdown: true
end
context [mrkdwn_text("Manage your files at <https://cdn.hackclub.com|cdn.hackclub.com>")]

View file

@ -0,0 +1,4 @@
Rails.application.config.slack = ActiveSupport::OrderedOptions.new
Rails.application.config.slack.bot_token = ENV.fetch("SLACK_BOT_TOKEN", nil)
Rails.application.config.slack.signing_secret = ENV.fetch("SLACK_SIGNING_SECRET", nil)
Rails.application.config.slack.cdn_channels = ENV.fetch("CDN_CHANNELS", "").split(",").map(&:strip)

View file

@ -44,6 +44,11 @@ Rails.application.routes.draw do
# Rescue endpoint to find uploads by original URL
get "/rescue", to: "external_uploads#rescue", as: :rescue_upload
# Slack events webhook
namespace :slack do
post "events", to: "events#create"
end
# External upload redirects (must be last to avoid conflicts)
get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload
end