diff --git a/Gemfile b/Gemfile index 84ba85e..32f0a39 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index ca04bbe..831235f 100644 --- a/Gemfile.lock +++ b/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 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 diff --git a/app/controllers/slack/events_controller.rb b/app/controllers/slack/events_controller.rb new file mode 100644 index 0000000..6a9f378 --- /dev/null +++ b/app/controllers/slack/events_controller.rb @@ -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 diff --git a/app/jobs/process_slack_file_upload_job.rb b/app/jobs/process_slack_file_upload_job.rb new file mode 100644 index 0000000..a1bb961 --- /dev/null +++ b/app/jobs/process_slack_file_upload_job.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 43a933a..7ab3469 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,7 +12,7 @@ class User < ApplicationRecord 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 encrypts :hca_access_token diff --git a/app/services/slack_service.rb b/app/services/slack_service.rb new file mode 100644 index 0000000..155db8c --- /dev/null +++ b/app/services/slack_service.rb @@ -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 diff --git a/app/views/slack/upload_error.slack_message.slocks b/app/views/slack/upload_error.slack_message.slocks new file mode 100644 index 0000000..f889ab3 --- /dev/null +++ b/app/views/slack/upload_error.slack_message.slocks @@ -0,0 +1,5 @@ +section "#{flavor_message}", markdown: true + +section ":warning: `#{error_message}`\n```#{backtrace}```", markdown: true + +context [mrkdwn_text("sentry ID: `#{sentry_id}` • ")] diff --git a/app/views/slack/upload_success.slack_message.slocks b/app/views/slack/upload_success.slack_message.slocks new file mode 100644 index 0000000..10f4771 --- /dev/null +++ b/app/views/slack/upload_success.slack_message.slocks @@ -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 !)")] diff --git a/config/initializers/slack.rb b/config/initializers/slack.rb new file mode 100644 index 0000000..c5dde74 --- /dev/null +++ b/config/initializers/slack.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index b9341e6..3cc5d31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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