mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 16:18:17 +00:00
add builtin slack integration (#23)
This commit is contained in:
parent
ae7f38e8bd
commit
cd73ee5b57
10 changed files with 351 additions and 1 deletions
2
Gemfile
2
Gemfile
|
|
@ -71,3 +71,5 @@ gem "blind_index"
|
||||||
gem "sentry-ruby"
|
gem "sentry-ruby"
|
||||||
gem "sentry-rails"
|
gem "sentry-rails"
|
||||||
gem "aws-sdk-s3", require: false
|
gem "aws-sdk-s3", require: false
|
||||||
|
gem "slack-ruby-client"
|
||||||
|
gem "slocks", git: "https://github.com/24c02/slocks"
|
||||||
|
|
|
||||||
26
Gemfile.lock
26
Gemfile.lock
|
|
@ -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
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
|
|
@ -140,12 +148,19 @@ GEM
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
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)
|
faraday-net_http (3.4.2)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
fiddle (1.1.8)
|
fiddle (1.1.8)
|
||||||
fugit (1.12.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
|
gli (2.22.2)
|
||||||
|
ostruct
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
hashid-rails (1.4.1)
|
hashid-rails (1.4.1)
|
||||||
|
|
@ -201,6 +216,7 @@ GEM
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_xml (0.8.1)
|
multi_xml (0.8.1)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.9.1)
|
net-http (0.9.1)
|
||||||
uri (>= 0.11.1)
|
uri (>= 0.11.1)
|
||||||
|
|
@ -249,6 +265,7 @@ GEM
|
||||||
omniauth-oauth2 (1.9.0)
|
omniauth-oauth2 (1.9.0)
|
||||||
oauth2 (>= 2.0.2, < 3)
|
oauth2 (>= 2.0.2, < 3)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.10.1)
|
parser (3.3.10.1)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
|
@ -400,6 +417,13 @@ GEM
|
||||||
sentry-ruby (6.3.0)
|
sentry-ruby (6.3.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
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)
|
snaky_hash (2.0.3)
|
||||||
hashie (>= 0.1.0, < 6)
|
hashie (>= 0.1.0, < 6)
|
||||||
version_gem (>= 1.1.8, < 3)
|
version_gem (>= 1.1.8, < 3)
|
||||||
|
|
@ -506,6 +530,8 @@ DEPENDENCIES
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails
|
sentry-rails
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
|
slack-ruby-client
|
||||||
|
slocks!
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue
|
solid_queue
|
||||||
|
|
|
||||||
41
app/controllers/slack/events_controller.rb
Normal file
41
app/controllers/slack/events_controller.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Slack
|
||||||
|
class EventsController < ActionController::API
|
||||||
|
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
|
||||||
|
::Slack::Events::Request.new(request).verify!
|
||||||
|
rescue ::Slack::Events::Request::MissingSigningSecret,
|
||||||
|
::Slack::Events::Request::InvalidSignature,
|
||||||
|
::Slack::Events::Request::TimestampExpired
|
||||||
|
render json: { error: "Invalid signature" }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def monitored_channel?(channel_id)
|
||||||
|
Rails.application.config.slack.cdn_channels.include?(channel_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
249
app/jobs/process_slack_file_upload_job.rb
Normal file
249
app/jobs/process_slack_file_upload_job.rb
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ProcessSlackFileUploadJob < ApplicationJob
|
||||||
|
include ActionView::Helpers::NumberHelper
|
||||||
|
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
class QuotaExceededError < StandardError
|
||||||
|
attr_reader :reason, :details
|
||||||
|
|
||||||
|
def initialize(reason, details = nil)
|
||||||
|
@reason = reason
|
||||||
|
@details = details
|
||||||
|
super(details || reason.to_s)
|
||||||
|
end
|
||||||
|
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 = SlackService.client
|
||||||
|
@user = nil
|
||||||
|
|
||||||
|
begin
|
||||||
|
@user = find_or_create_user
|
||||||
|
|
||||||
|
add_reaction("beachball")
|
||||||
|
reply_in_thread(pick_flavor_message)
|
||||||
|
|
||||||
|
uploads = process_files
|
||||||
|
notify_success(uploads)
|
||||||
|
rescue QuotaExceededError => e
|
||||||
|
notify_quota_exceeded(e)
|
||||||
|
rescue => e
|
||||||
|
notify_error(e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_or_create_user
|
||||||
|
user = User.find_by(slack_id: @slack_user_id)
|
||||||
|
|
||||||
|
unless user
|
||||||
|
profile = @slack.users_info(user: @slack_user_id).user
|
||||||
|
|
||||||
|
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 process_files
|
||||||
|
uploads = []
|
||||||
|
|
||||||
|
@files.each do |file|
|
||||||
|
original_url = file["url_private"]
|
||||||
|
upload = Upload.create_from_url(
|
||||||
|
original_url,
|
||||||
|
user: @user,
|
||||||
|
provenance: :slack,
|
||||||
|
original_url: original_url,
|
||||||
|
authorization: "Bearer #{Rails.application.config.slack.bot_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
enforce_quota!(upload)
|
||||||
|
uploads << upload
|
||||||
|
end
|
||||||
|
|
||||||
|
uploads
|
||||||
|
end
|
||||||
|
|
||||||
|
def enforce_quota!(upload)
|
||||||
|
quota_service = QuotaService.new(@user)
|
||||||
|
policy = quota_service.current_policy
|
||||||
|
|
||||||
|
if upload.byte_size > policy.max_file_size
|
||||||
|
upload.destroy!
|
||||||
|
raise QuotaExceededError.new(
|
||||||
|
:file_too_large,
|
||||||
|
"File is #{number_to_human_size(upload.byte_size)} but max is #{number_to_human_size(policy.max_file_size)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return unless @user.total_storage_bytes > policy.max_total_storage
|
||||||
|
|
||||||
|
upload.destroy!
|
||||||
|
raise QuotaExceededError.new(
|
||||||
|
:storage_exceeded,
|
||||||
|
"You've used #{number_to_human_size(@user.total_storage_bytes)} of your #{number_to_human_size(policy.max_total_storage)} storage"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_success(uploads)
|
||||||
|
remove_reaction("beachball")
|
||||||
|
add_reaction("white_check_mark")
|
||||||
|
|
||||||
|
@slack.chat_postMessage(
|
||||||
|
channel: @channel_id,
|
||||||
|
thread_ts: @message_ts,
|
||||||
|
text: "Yeah! Here's yo' links",
|
||||||
|
**render_slack_template("upload_success", uploads: uploads, slack_user_id: @slack_user_id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_quota_exceeded(error)
|
||||||
|
remove_reaction("beachball")
|
||||||
|
add_reaction("x")
|
||||||
|
|
||||||
|
error_text = case error.reason
|
||||||
|
when :file_too_large
|
||||||
|
[
|
||||||
|
"_cdnpheus tries to pick up the file but it's too heavy. she strains. she sweats. she gives up._ #{error.details}",
|
||||||
|
"whoa there, that file is THICC. #{error.details} – verify at cdn.hackclub.com for chonkier uploads!",
|
||||||
|
"_cdnpheus attempts to stuff the file into her tiny dinosaur backpack. it does not fit._ #{error.details}",
|
||||||
|
"i tried to eat this file but it's too big and i'm just a small dinosaur :( #{error.details}"
|
||||||
|
].sample
|
||||||
|
when :storage_exceeded
|
||||||
|
[
|
||||||
|
"_cdnpheus opens her filing cabinet but papers explode everywhere._ you're out of space! #{error.details}",
|
||||||
|
"your storage is fuller than my inbox after i mass-DM'd everyone about my soundcloud. #{error.details}",
|
||||||
|
"no room at the inn! #{error.details} – delete some files or verify at cdn.hackclub.com for more space"
|
||||||
|
].sample
|
||||||
|
else
|
||||||
|
[
|
||||||
|
"quota exceeded! verify at cdn.hackclub.com to unlock your true potential",
|
||||||
|
"_cdnpheus taps the \"quota exceeded\" sign apologetically_"
|
||||||
|
].sample
|
||||||
|
end
|
||||||
|
|
||||||
|
reply_in_thread(error_text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_error(error)
|
||||||
|
Rails.logger.error "Slack file upload failed: #{error.message}\n#{error.backtrace.join("\n")}"
|
||||||
|
sentry_event = Sentry.capture_exception(error)
|
||||||
|
sentry_id = sentry_event&.event_id || "unknown"
|
||||||
|
|
||||||
|
return unless @slack
|
||||||
|
|
||||||
|
begin
|
||||||
|
remove_reaction("beachball")
|
||||||
|
add_reaction("x")
|
||||||
|
|
||||||
|
@slack.chat_postMessage(
|
||||||
|
channel: @channel_id,
|
||||||
|
thread_ts: @message_ts,
|
||||||
|
text: "Something went wrong uploading your file",
|
||||||
|
**render_slack_template("upload_error",
|
||||||
|
flavor_message: pick_error_message,
|
||||||
|
error_message: error.message,
|
||||||
|
backtrace: format_backtrace(error.backtrace),
|
||||||
|
sentry_id: sentry_id)
|
||||||
|
)
|
||||||
|
rescue => slack_error
|
||||||
|
Rails.logger.error "Failed to send Slack error notification: #{slack_error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_reaction(emoji)
|
||||||
|
@slack.reactions_add(channel: @channel_id, timestamp: @message_ts, name: emoji)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_reaction(emoji)
|
||||||
|
@slack.reactions_remove(channel: @channel_id, timestamp: @message_ts, name: emoji)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_in_thread(text)
|
||||||
|
@slack.chat_postMessage(channel: @channel_id, thread_ts: @message_ts, text: text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_slack_template(template, locals = {})
|
||||||
|
json = ApplicationController.render(
|
||||||
|
template: "slack/#{template}",
|
||||||
|
formats: [ :slack_message ],
|
||||||
|
locals:
|
||||||
|
)
|
||||||
|
JSON.parse(json, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pick_flavor_message
|
||||||
|
# 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 format_backtrace(backtrace)
|
||||||
|
return "" if backtrace.blank?
|
||||||
|
|
||||||
|
Rails.backtrace_cleaner.clean(backtrace).first(3).map do |line|
|
||||||
|
if line =~ /^(.+):(\d+):in\s+'(.+)'$/
|
||||||
|
file, line_num, method_name = $1, $2, $3
|
||||||
|
url = "https://github.com/hackclub/cdn/blob/main/#{file}#L#{line_num}"
|
||||||
|
"<#{url}|#{file}:#{line_num}> in `#{method_name}`"
|
||||||
|
elsif line =~ /^(.+):(\d+)/
|
||||||
|
file, line_num = $1, $2
|
||||||
|
url = "https://github.com/hackclub/cdn/blob/main/#{file}#L#{line_num}"
|
||||||
|
"<#{url}|#{file}:#{line_num}>"
|
||||||
|
else
|
||||||
|
line
|
||||||
|
end
|
||||||
|
end.join("\n")
|
||||||
|
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._",
|
||||||
|
"Hmmm... I'm having trouble thinking right now. Whenever I focus, the only thing that comes to mind is this error",
|
||||||
|
"Aw jeez, this is embarrassing. My database just texted me this",
|
||||||
|
"I just opened my notebook to take a note, but it just says this error all over the pages",
|
||||||
|
"Do you ever try to remember something, but end up thinking about server errors instead? Wait... what were we talking about?",
|
||||||
|
"Super embarrassing, but I just forgot how to upload files.",
|
||||||
|
"i live. i hunger. i. fail to upload your file. i. am. sinister.",
|
||||||
|
"_cdnpheus tries to catch the file but it phases through her claws like a ghost. spooky._"
|
||||||
|
].sample
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -12,7 +12,7 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
scope :admins, -> { where(is_admin: true) }
|
scope :admins, -> { where(is_admin: true) }
|
||||||
|
|
||||||
validates :hca_id, presence: true, uniqueness: true
|
validates :hca_id, uniqueness: true, allow_nil: true
|
||||||
validates :quota_policy, inclusion: { in: Quota::ADMIN_ASSIGNABLE.map(&:to_s) }, allow_nil: true
|
validates :quota_policy, inclusion: { in: Quota::ADMIN_ASSIGNABLE.map(&:to_s) }, allow_nil: true
|
||||||
encrypts :hca_access_token
|
encrypts :hca_access_token
|
||||||
|
|
||||||
|
|
|
||||||
7
app/services/slack_service.rb
Normal file
7
app/services/slack_service.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SlackService
|
||||||
|
def self.client
|
||||||
|
@client ||= Slack::Web::Client.new(token: Rails.application.config.slack.bot_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
5
app/views/slack/upload_error.slack_message.slocks
Normal file
5
app/views/slack/upload_error.slack_message.slocks
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
section "#{flavor_message}", markdown: true
|
||||||
|
|
||||||
|
section ":warning: `#{error_message}`\n```#{backtrace}```", markdown: true
|
||||||
|
|
||||||
|
context [mrkdwn_text("sentry ID: `#{sentry_id}` • <https://cdn.hackclub.com|cdn.hackclub.com>")]
|
||||||
7
app/views/slack/upload_success.slack_message.slocks
Normal file
7
app/views/slack/upload_success.slack_message.slocks
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
section "Yeah! Here's yo' links <@#{slack_user_id}>!", markdown: true
|
||||||
|
|
||||||
|
uploads.each do |upload|
|
||||||
|
section "#{upload.cdn_url}", markdown: true
|
||||||
|
end
|
||||||
|
|
||||||
|
context [mrkdwn_text("(manage your files at <https://cdn.hackclub.com|cdn.hackclub.com>!)")]
|
||||||
8
config/initializers/slack.rb
Normal file
8
config/initializers/slack.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
Slack::Events.configure do |config|
|
||||||
|
config.signing_secret = Rails.application.config.slack.signing_secret
|
||||||
|
end
|
||||||
|
|
@ -44,6 +44,11 @@ Rails.application.routes.draw do
|
||||||
# Rescue endpoint to find uploads by original URL
|
# Rescue endpoint to find uploads by original URL
|
||||||
get "/rescue", to: "external_uploads#rescue", as: :rescue_upload
|
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)
|
# 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
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue