You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

387 lines
14 KiB

  1. # frozen_string_literal: true
  2. require_relative '../../config/boot'
  3. require_relative '../../config/environment'
  4. require_relative 'cli_helper'
  5. module Mastodon
  6. class MediaCLI < Thor
  7. include ActionView::Helpers::NumberHelper
  8. include CLIHelper
  9. def self.exit_on_failure?
  10. true
  11. end
  12. option :days, type: :numeric, default: 7, aliases: [:d]
  13. option :prune_profiles, type: :boolean, default: false
  14. option :remove_headers, type: :boolean, default: false
  15. option :include_follows, type: :boolean, default: false
  16. option :concurrency, type: :numeric, default: 5, aliases: [:c]
  17. option :dry_run, type: :boolean, default: false
  18. desc 'remove', 'Remove remote media files, headers or avatars'
  19. long_desc <<-DESC
  20. Removes locally cached copies of media attachments (and optionally profile
  21. headers and avatars) from other servers. By default, only media attachements
  22. are removed.
  23. The --days option specifies how old media attachments have to be before
  24. they are removed. In case of avatars and headers, it specifies how old
  25. the last webfinger request and update to the user has to be before they
  26. are pruned. It defaults to 7 days.
  27. If --prune-profiles is specified, only avatars and headers are removed.
  28. If --remove-headers is specified, only headers are removed.
  29. If --include-follows is specified along with --prune-profiles or
  30. --remove-headers, all non-local profiles will be pruned irrespective of
  31. follow status. By default, only accounts that are not followed by or
  32. following anyone locally are pruned.
  33. DESC
  34. # rubocop:disable Metrics/PerceivedComplexity
  35. def remove
  36. if options[:prune_profiles] && options[:remove_headers]
  37. say('--prune-profiles and --remove-headers should not be specified simultaneously', :red, true)
  38. exit(1)
  39. end
  40. if options[:include_follows] && !(options[:prune_profiles] || options[:remove_headers])
  41. say('--include-follows can only be used with --prune-profiles or --remove-headers', :red, true)
  42. exit(1)
  43. end
  44. time_ago = options[:days].days.ago
  45. dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
  46. if options[:prune_profiles] || options[:remove_headers]
  47. processed, aggregate = parallelize_with_progress(Account.remote.where({ last_webfingered_at: ..time_ago, updated_at: ..time_ago })) do |account|
  48. next if !options[:include_follows] && Follow.where(account: account).or(Follow.where(target_account: account)).exists?
  49. next if account.avatar.blank? && account.header.blank?
  50. next if options[:remove_headers] && account.header.blank?
  51. size = (account.header_file_size || 0)
  52. size += (account.avatar_file_size || 0) if options[:prune_profiles]
  53. unless options[:dry_run]
  54. account.header.destroy
  55. account.avatar.destroy if options[:prune_profiles]
  56. account.save!
  57. end
  58. size
  59. end
  60. say("Visited #{processed} accounts and removed profile media totaling #{number_to_human_size(aggregate)}#{dry_run}", :green, true)
  61. end
  62. unless options[:prune_profiles] || options[:remove_headers]
  63. processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where(created_at: ..time_ago)) do |media_attachment|
  64. next if media_attachment.file.blank?
  65. size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
  66. unless options[:dry_run]
  67. media_attachment.file.destroy
  68. media_attachment.thumbnail.destroy
  69. media_attachment.save
  70. end
  71. size
  72. end
  73. say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
  74. end
  75. end
  76. option :start_after
  77. option :prefix
  78. option :fix_permissions, type: :boolean, default: false
  79. option :dry_run, type: :boolean, default: false
  80. desc 'remove-orphans', 'Scan storage and check for files that do not belong to existing media attachments'
  81. long_desc <<~LONG_DESC
  82. Scans file storage for files that do not belong to existing media attachments. Because this operation
  83. requires iterating over every single file individually, it will be slow.
  84. Please mind that some storage providers charge for the necessary API requests to list objects.
  85. LONG_DESC
  86. def remove_orphans
  87. progress = create_progress_bar(nil)
  88. reclaimed_bytes = 0
  89. removed = 0
  90. dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
  91. prefix = options[:prefix]
  92. case Paperclip::Attachment.default_options[:storage]
  93. when :s3
  94. paperclip_instance = MediaAttachment.new.file
  95. s3_interface = paperclip_instance.s3_interface
  96. s3_permissions = Paperclip::Attachment.default_options[:s3_permissions]
  97. bucket = s3_interface.bucket(Paperclip::Attachment.default_options[:s3_credentials][:bucket])
  98. last_key = options[:start_after]
  99. loop do
  100. objects = begin
  101. begin
  102. bucket.objects(start_after: last_key, prefix: prefix).limit(1000).map { |x| x }
  103. rescue => e
  104. progress.log(pastel.red("Error fetching list of files: #{e}"))
  105. progress.log("If you want to continue from this point, add --start-after=#{last_key} to your command") if last_key
  106. break
  107. end
  108. end
  109. break if objects.empty?
  110. last_key = objects.last.key
  111. record_map = preload_records_from_mixed_objects(objects)
  112. objects.each do |object|
  113. object.acl.put(acl: s3_permissions) if options[:fix_permissions] && !options[:dry_run]
  114. path_segments = object.key.split('/')
  115. path_segments.delete('cache')
  116. unless [7, 10].include?(path_segments.size)
  117. progress.log(pastel.yellow("Unrecognized file found: #{object.key}"))
  118. next
  119. end
  120. model_name = path_segments.first.classify
  121. attachment_name = path_segments[1].singularize
  122. record_id = path_segments[2..-2].join.to_i
  123. file_name = path_segments.last
  124. record = record_map.dig(model_name, record_id)
  125. attachment = record&.public_send(attachment_name)
  126. progress.increment
  127. next unless attachment.blank? || !attachment.variant?(file_name)
  128. begin
  129. object.delete unless options[:dry_run]
  130. reclaimed_bytes += object.size
  131. removed += 1
  132. progress.log("Found and removed orphan: #{object.key}")
  133. rescue => e
  134. progress.log(pastel.red("Error processing #{object.key}: #{e}"))
  135. end
  136. end
  137. end
  138. when :fog
  139. say('The fog storage driver is not supported for this operation at this time', :red)
  140. exit(1)
  141. when :filesystem
  142. require 'find'
  143. root_path = ENV.fetch('PAPERCLIP_ROOT_PATH', File.join(':rails_root', 'public', 'system')).gsub(':rails_root', Rails.root.to_s)
  144. Find.find(File.join(*[root_path, prefix].compact)) do |path|
  145. next if File.directory?(path)
  146. key = path.gsub("#{root_path}#{File::SEPARATOR}", '')
  147. path_segments = key.split(File::SEPARATOR)
  148. path_segments.delete('cache')
  149. unless [7, 10].include?(path_segments.size)
  150. progress.log(pastel.yellow("Unrecognized file found: #{key}"))
  151. next
  152. end
  153. model_name = path_segments.first.classify
  154. record_id = path_segments[2..-2].join.to_i
  155. attachment_name = path_segments[1].singularize
  156. file_name = path_segments.last
  157. next unless PRELOAD_MODEL_WHITELIST.include?(model_name)
  158. record = model_name.constantize.find_by(id: record_id)
  159. attachment = record&.public_send(attachment_name)
  160. progress.increment
  161. next unless attachment.blank? || !attachment.variant?(file_name)
  162. begin
  163. size = File.size(path)
  164. unless options[:dry_run]
  165. File.delete(path)
  166. begin
  167. FileUtils.rmdir(File.dirname(path), parents: true)
  168. rescue Errno::ENOTEMPTY
  169. # OK
  170. end
  171. end
  172. reclaimed_bytes += size
  173. removed += 1
  174. progress.log("Found and removed orphan: #{key}")
  175. rescue => e
  176. progress.log(pastel.red("Error processing #{key}: #{e}"))
  177. end
  178. end
  179. end
  180. progress.total = progress.progress
  181. progress.finish
  182. say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true)
  183. end
  184. # rubocop:enable Metrics/PerceivedComplexity
  185. option :account, type: :string
  186. option :domain, type: :string
  187. option :status, type: :numeric
  188. option :days, type: :numeric
  189. option :concurrency, type: :numeric, default: 5, aliases: [:c]
  190. option :verbose, type: :boolean, default: false, aliases: [:v]
  191. option :dry_run, type: :boolean, default: false
  192. option :force, type: :boolean, default: false
  193. desc 'refresh', 'Fetch remote media files'
  194. long_desc <<-DESC
  195. Re-downloads media attachments from other servers. You must specify the
  196. source of media attachments with one of the following options:
  197. Use the --status option to download attachments from a specific status,
  198. using the status local numeric ID.
  199. Use the --account option to download attachments from a specific account,
  200. using username@domain handle of the account.
  201. Use the --domain option to download attachments from a specific domain.
  202. Use the --days option to limit attachments created within days.
  203. By default, attachments that are believed to be already downloaded will
  204. not be re-downloaded. To force re-download of every URL, use --force.
  205. DESC
  206. def refresh
  207. dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
  208. if options[:status]
  209. scope = MediaAttachment.where(status_id: options[:status])
  210. elsif options[:account]
  211. username, domain = options[:account].split('@')
  212. account = Account.find_remote(username, domain)
  213. if account.nil?
  214. say('No such account', :red)
  215. exit(1)
  216. end
  217. scope = MediaAttachment.where(account_id: account.id)
  218. elsif options[:domain]
  219. scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain]))
  220. elsif options[:days].present?
  221. scope = MediaAttachment.remote
  222. else
  223. exit(1)
  224. end
  225. if options[:days].present?
  226. scope = scope.where('media_attachments.id > ?', Mastodon::Snowflake.id_at(options[:days].days.ago, with_random: false))
  227. end
  228. processed, aggregate = parallelize_with_progress(scope) do |media_attachment|
  229. next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
  230. next if DomainBlock.reject_media?(media_attachment.account.domain)
  231. unless options[:dry_run]
  232. media_attachment.reset_file!
  233. media_attachment.reset_thumbnail!
  234. media_attachment.save
  235. end
  236. media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0)
  237. end
  238. say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
  239. end
  240. desc 'usage', 'Calculate disk space consumed by Mastodon'
  241. def usage
  242. say("Attachments:\t#{number_to_human_size(MediaAttachment.sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')))} (#{number_to_human_size(MediaAttachment.where(account: Account.local).sum(Arel.sql('COALESCE(file_file_size, 0) + COALESCE(thumbnail_file_size, 0)')))} local)")
  243. say("Custom emoji:\t#{number_to_human_size(CustomEmoji.sum(:image_file_size))} (#{number_to_human_size(CustomEmoji.local.sum(:image_file_size))} local)")
  244. say("Preview cards:\t#{number_to_human_size(PreviewCard.sum(:image_file_size))}")
  245. say("Avatars:\t#{number_to_human_size(Account.sum(:avatar_file_size))} (#{number_to_human_size(Account.local.sum(:avatar_file_size))} local)")
  246. say("Headers:\t#{number_to_human_size(Account.sum(:header_file_size))} (#{number_to_human_size(Account.local.sum(:header_file_size))} local)")
  247. say("Backups:\t#{number_to_human_size(Backup.sum(:dump_file_size))}")
  248. say("Imports:\t#{number_to_human_size(Import.sum(:data_file_size))}")
  249. say("Settings:\t#{number_to_human_size(SiteUpload.sum(:file_file_size))}")
  250. end
  251. desc 'lookup URL', 'Lookup where media is displayed by passing a media URL'
  252. def lookup(url)
  253. path = Addressable::URI.parse(url).path
  254. path_segments = path.split('/')[2..]
  255. path_segments.delete('cache')
  256. unless [7, 10].include?(path_segments.size)
  257. say('Not a media URL', :red)
  258. exit(1)
  259. end
  260. model_name = path_segments.first.classify
  261. record_id = path_segments[2..-2].join.to_i
  262. unless PRELOAD_MODEL_WHITELIST.include?(model_name)
  263. say("Cannot find corresponding model: #{model_name}", :red)
  264. exit(1)
  265. end
  266. record = model_name.constantize.find_by(id: record_id)
  267. record = record.status if record.respond_to?(:status)
  268. unless record
  269. say('Cannot find corresponding record', :red)
  270. exit(1)
  271. end
  272. display_url = ActivityPub::TagManager.instance.url_for(record)
  273. if display_url.blank?
  274. say('No public URL for this type of record', :red)
  275. exit(1)
  276. end
  277. say(display_url, :blue)
  278. rescue Addressable::URI::InvalidURIError
  279. say('Invalid URL', :red)
  280. exit(1)
  281. end
  282. private
  283. PRELOAD_MODEL_WHITELIST = %w(
  284. Account
  285. Backup
  286. CustomEmoji
  287. Import
  288. MediaAttachment
  289. PreviewCard
  290. SiteUpload
  291. ).freeze
  292. def preload_records_from_mixed_objects(objects)
  293. preload_map = Hash.new { |hash, key| hash[key] = [] }
  294. objects.map do |object|
  295. segments = object.key.split('/')
  296. segments.delete('cache')
  297. next unless [7, 10].include?(segments.size)
  298. model_name = segments.first.classify
  299. record_id = segments[2..-2].join.to_i
  300. next unless PRELOAD_MODEL_WHITELIST.include?(model_name)
  301. preload_map[model_name] << record_id
  302. end
  303. preload_map.each_with_object({}) do |(model_name, record_ids), model_map|
  304. model_map[model_name] = model_name.constantize.where(id: record_ids).index_by(&:id)
  305. end
  306. end
  307. end
  308. end