class Dcc
Constants
- IMAGE_EXTENSIONS
- INDEX_KEY
- MAX_FILES
- MAX_FILE_SIZE
- PLUGIN_NAME
- STORAGE_ROOT
- STYLE_TITLE
Public Class Methods
Source
# File plugins/dcc.rb, line 77 def self.absolute_path(entry, storage_root: STORAGE_ROOT) File.join(storage_root, entry["path"].to_s) end
Source
# File plugins/dcc.rb, line 49 def self.accept_incoming?(dcc) !(dcc.from_private_ip? || dcc.from_localhost?) end
Source
# File plugins/dcc.rb, line 208 def self.actor_owns_file?(actor_nick, entry) Normalize.user_key(actor_nick) == Normalize.user_key(entry["nick"]) end
Source
# File plugins/dcc.rb, line 198 def self.admin_allowed?(network:, channel:, nick:, is_op:, store: RabbotDb) store.user_allowed_at_level?( network: network, channel: channel, nick: nick, is_op: is_op, minimum: RabbotDb::LEVEL_ADMIN ) end
Source
# File plugins/dcc.rb, line 145 def self.delete_file(network:, id:, storage_root: STORAGE_ROOT, store: RabbotDb) entry = find_file(network: network, id: id, store: store) return "Unknown file id #{id}." unless entry path = absolute_path(entry, storage_root: storage_root) File.delete(path) if File.file?(path) index = load_index(network: network, store: store) index.reject! { |item| item["id"] == entry["id"] } save_index(network: network, index: index, store: store) "Deleted #{entry['id']} (#{entry['filename']})." end
Source
# File plugins/dcc.rb, line 36 def self.denied_admin_message "Only registered admins can manage DCC files." end
Source
# File plugins/dcc.rb, line 40 def self.denied_view_message "You cannot view that file." end
Source
# File plugins/dcc.rb, line 131 def self.files_for(network:, nick: nil, store: RabbotDb) entries = load_index(network: network, store: store) return entries unless nick user_key = Normalize.user_key(nick) entries.select { |entry| Normalize.user_key(entry["nick"]) == user_key } end
Source
# File plugins/dcc.rb, line 126 def self.find_file(network:, id:, store: RabbotDb) key = id.to_s.strip.downcase load_index(network: network, store: store).find { |entry| entry["id"].to_s.downcase == key } end
Source
# File plugins/dcc.rb, line 239 def self.format_draw_offer(entry) [ IrcFormat.report_header(tag: "DCC", title: "offering file", style: STYLE_TITLE), "Sending #{entry['filename']} (#{human_size(entry['size'])}) via DCC.", IrcFormat.report_footer(entry["nick"]) ].join("\n") end
Source
# File plugins/dcc.rb, line 212 def self.format_file_line(entry) kind = entry["image"] ? "image" : "file" "#{entry['id']} — #{entry['filename']} (#{kind}, #{human_size(entry['size'])}, #{entry['nick']})" end
Source
# File plugins/dcc.rb, line 217 def self.format_list_report(files:) lines = [IrcFormat.report_header(tag: "DCC", title: "incoming files", style: STYLE_TITLE)] if files.empty? lines << "No files on file." else files.each { |entry| lines << format_file_line(entry) } end lines << IrcFormat.report_footer("#{files.length} items") lines.join("\n") end
Source
# File plugins/dcc.rb, line 228 def self.format_show_report(entry) kind = entry["image"] ? "image" : "file" lines = [ IrcFormat.report_header(tag: "DCC", title: entry["filename"], style: STYLE_TITLE), "#{entry['id']} — #{kind} — #{human_size(entry['size'])} — #{entry['nick']}", "received #{entry['received_at']}", IrcFormat.report_footer("dcc file") ] lines.join("\n") end
Source
# File plugins/dcc.rb, line 81 def self.human_size(bytes) size = bytes.to_i return "#{size} B" if size < 1024 return "#{(size / 1024.0).round(1)} KB" if size < 1_048_576 "#{(size / 1_048_576.0).round(1)} MB" end
Source
# File plugins/dcc.rb, line 44 def self.image_filename?(filename) ext = File.extname(filename.to_s).downcase IMAGE_EXTENSIONS.include?(ext) end
Source
# File plugins/dcc.rb, line 139 def self.last_image_for(network:, nick:, store: RabbotDb) files_for(network: network, nick: nick, store: store) .select { |entry| entry["image"] } .max_by { |entry| entry["received_at"].to_s } end
Source
# File plugins/dcc.rb, line 65 def self.load_index(network:, store: RabbotDb) Array(store.get_plugin_json(plugin: PLUGIN_NAME, key: INDEX_KEY, network: network, default: [])) end
Source
# File plugins/dcc.rb, line 247 def self.manage_command(network:, channel:, args:, actor_nick:, is_op:, storage_root: STORAGE_ROOT, store: RabbotDb) parsed = parse_command(args) return parsed[:error] if parsed[:error] admin = admin_allowed?(network: network, channel: channel, nick: actor_nick, is_op: is_op, store: store) case parsed[:action] when :list if parsed[:nick] return denied_admin_message unless admin files = files_for(network: network, nick: parsed[:nick], store: store) elsif admin files = load_index(network: network, store: store).last(20) else files = files_for(network: network, nick: actor_nick, store: store).last(20) end format_list_report(files: files) when :show entry = find_file(network: network, id: parsed[:id], store: store) return "Unknown file id #{parsed[:id]}." unless entry unless admin || actor_owns_file?(actor_nick, entry) return denied_view_message end format_show_report(entry) when :del return denied_admin_message unless admin delete_file(network: network, id: parsed[:id], storage_root: storage_root, store: store) when :purge return denied_admin_message unless admin purge_nick(network: network, nick: parsed[:nick], storage_root: storage_root, store: store) when :draw prepare_draw( network: network, channel: channel, args: args, actor_nick: actor_nick, is_op: is_op, storage_root: storage_root, store: store )[:offer_line] end end
Source
# File plugins/dcc.rb, line 57 def self.network_key(network) RabbotDb.normalize_network(network) end
Source
# File plugins/dcc.rb, line 169 def self.parse_command(text) stripped = text.to_s.strip return { action: :list } if stripped.empty? if stripped.casecmp?("list") || (match = stripped.match(/\Alist(?:\s+(\S+))?\z/i)) nick = match ? match[1] : nil return { action: :list, nick: nick && Normalize.nick(match[1]) } end if (match = stripped.match(/\Ashow\s+(\S+)\z/i)) return { action: :show, id: match[1] } end if (match = stripped.match(/\Adel\s+(\S+)\z/i)) return { action: :del, id: match[1] } end if (match = stripped.match(/\Apurge\s+(\S+)\z/i)) return { action: :purge, nick: Normalize.nick(match[1]) } end if stripped.casecmp?("draw") || (match = stripped.match(/\Adraw(?:\s+(\S+))?\z/i)) nick = match ? match[1] : nil return { action: :draw, nick: nick && Normalize.nick(nick) } end { error: usage_message } end
Source
# File plugins/dcc.rb, line 294 def self.prepare_draw(network:, channel:, args:, actor_nick:, is_op:, storage_root: STORAGE_ROOT, store: RabbotDb) parsed = parse_command(args) target_nick = parsed[:nick] || actor_nick admin = admin_allowed?(network: network, channel: channel, nick: actor_nick, is_op: is_op, store: store) if parsed[:nick] && !admin && !actor_owns_file?(actor_nick, { "nick" => target_nick }) return { error: denied_admin_message } end entry = last_image_for(network: network, nick: target_nick, store: store) return { error: "No image on file for #{target_nick}." } unless entry path = absolute_path(entry, storage_root: storage_root) return { error: "That file is no longer available." } unless File.file?(path) { file: entry, path: path, offer_line: format_draw_offer(entry) } end
Source
# File plugins/dcc.rb, line 157 def self.purge_nick(network:, nick:, storage_root: STORAGE_ROOT, store: RabbotDb) user_key = Normalize.user_key(nick) index = load_index(network: network, store: store) removed, kept = index.partition { |entry| Normalize.user_key(entry["nick"]) == user_key } removed.each do |entry| path = absolute_path(entry, storage_root: storage_root) File.delete(path) if File.file?(path) end save_index(network: network, index: kept, store: store) "Purged #{removed.length} file(s) for #{Normalize.nick(nick)}." end
Source
# File plugins/dcc.rb, line 89 def self.register_file(network:, nick:, filename:, size:, source_path:, storage_root: STORAGE_ROOT, store: RabbotDb, received_at: nil) safe_name = sanitize_filename(filename) raise ArgumentError, "filename cannot be empty" if safe_name.empty? raise ArgumentError, "file too large" if size.to_i > MAX_FILE_SIZE id = generate_id network_dir = storage_dir(network: network, storage_root: storage_root) FileUtils.mkdir_p(network_dir) relative = File.join(network_key(network), "#{id}_#{safe_name}") dest = File.join(storage_root, relative) FileUtils.cp(source_path, dest) entry = { "id" => id, "nick" => Normalize.nick(nick), "filename" => safe_name, "size" => size.to_i, "received_at" => received_at || store.iso_time, "path" => relative, "image" => image_filename?(safe_name) } index = load_index(network: network, store: store) index << entry trim_index!(index, storage_root: storage_root) save_index(network: network, index: index, store: store) entry end
Source
# File plugins/dcc.rb, line 53 def self.sanitize_filename(filename) File.basename(File.expand_path(filename.to_s)).delete("/\\") end
Source
# File plugins/dcc.rb, line 69 def self.save_index(network:, index:, store: RabbotDb) store.set_plugin_json(plugin: PLUGIN_NAME, key: INDEX_KEY, network: network, value: index) end
Source
# File plugins/dcc.rb, line 61 def self.storage_dir(network:, storage_root: STORAGE_ROOT) File.join(storage_root, network_key(network)) end
Source
# File plugins/dcc.rb, line 118 def self.trim_index!(index, storage_root: STORAGE_ROOT) while index.length > MAX_FILES removed = index.shift path = absolute_path(removed, storage_root: storage_root) File.delete(path) if File.file?(path) end end
Source
# File plugins/dcc.rb, line 32 def self.usage_message "Usage: !dcc list [nick] | show <id> | draw [nick] | del <id> | purge <nick> (manage: registered admin)" end
Public Instance Methods
Source
# File plugins/dcc.rb, line 348 def execute(m, args = nil) unless m.channel m.reply "Use !dcc in a channel" return end parsed = self.class.parse_command(args) if parsed[:action] == :draw result = self.class.prepare_draw( network: bot.config.server, channel: m.channel.name, args: args, actor_nick: m.user.nick, is_op: m.channel.opped?(m.user) ) if result[:error] themed_flood_safe_reply(m, result[:error]) return end themed_flood_safe_reply(m, result[:offer_line]) m.user.dcc_send(File.open(result[:path], "rb"), result[:file]["filename"]) return end reply = self.class.manage_command( network: bot.config.server, channel: m.channel.name, args: args, actor_nick: m.user.nick, is_op: m.channel.opped?(m.user) ) themed_flood_safe_reply(m, reply) end
Source
# File plugins/dcc.rb, line 312 def on_dcc_send(_m, dcc) unless self.class.accept_incoming?(dcc) bot.loggers.debug "Rejected potentially unsafe DCC from #{dcc.user.nick}" return end if dcc.size.to_i > MAX_FILE_SIZE bot.loggers.debug "Rejected oversized DCC from #{dcc.user.nick}" return end network = bot.config.server network_dir = self.class.storage_dir(network: network) FileUtils.mkdir_p(network_dir) temp = File.join(network_dir, "incoming-#{Process.pid}-#{Thread.current.object_id}") success = dcc.accept(File.open(temp, "wb")) unless success File.delete(temp) if File.file?(temp) return end entry = self.class.register_file( network: network, nick: dcc.user.nick, filename: dcc.filename, size: dcc.size, source_path: temp ) File.delete(temp) if File.file?(temp) bot.loggers.info "Stored DCC #{entry['id']} (#{entry['filename']}) from #{entry['nick']}" DccImageHooks.dispatch(network: network, nick: entry["nick"], entry: entry) if entry["image"] rescue StandardError => e File.delete(temp) if defined?(temp) && File.file?(temp) bot.loggers.error "DCC receive failed: #{e.message}" end