mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 20:55:10 +00:00
awa
This commit is contained in:
parent
64a833329c
commit
83a57cbe9e
8 changed files with 332 additions and 0 deletions
2
Gemfile
2
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"
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
|
|||
44
app/controllers/slack/events_controller.rb
Normal file
44
app/controllers/slack/events_controller.rb
Normal 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
|
||||
192
app/jobs/process_slack_file_upload_job.rb
Normal file
192
app/jobs/process_slack_file_upload_job.rb
Normal 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
|
||||
52
app/services/slack_service.rb
Normal file
52
app/services/slack_service.rb
Normal 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
|
||||
7
app/views/slack/upload_success.slack_blocks
Normal file
7
app/views/slack/upload_success.slack_blocks
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}|#{upload.filename}>", markdown: true
|
||||
end
|
||||
|
||||
context [mrkdwn_text("Manage your files at <https://cdn.hackclub.com|cdn.hackclub.com>")]
|
||||
4
config/initializers/slack.rb
Normal file
4
config/initializers/slack.rb
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue