Files
danbooru/app/logical/danbooru/http.rb
evazion e935f01358 uploads: fix temp files not being cleaned up quickly enough.
Fix temp files generated during the upload process not being cleaned up quickly enough. This included
downloaded files, generated preview images, and Ugoira video conversions.

Before we relied on `Tempfile` cleaning up files automatically. But this only happened when the
Tempfile object was garbage collected, which could take a long time. In the meantime we could have
hundreds of megabytes of temp files hanging around.

The fix is to explicitly close temp files when we're done with them. But the standard `Tempfile`
class doesn't immediately delete the file when it's closed. So we also have to introduce a
Danbooru::Tempfile wrapper that deletes the tempfile as soon as it's closed.
2022-11-15 18:50:50 -06:00

244 lines
7.8 KiB
Ruby

# frozen_string_literal: true
require "danbooru/http/application_client"
require "danbooru/http/html_adapter"
require "danbooru/http/xml_adapter"
require "danbooru/http/cache"
require "danbooru/http/logger"
require "danbooru/http/redirector"
require "danbooru/http/retriable"
require "danbooru/http/session"
require "danbooru/http/spoof_referrer"
require "danbooru/http/unpolish_cloudflare"
# The HTTP client used by Danbooru for all outgoing HTTP requests. A wrapper
# around the http.rb gem that adds some helper methods and custom behavior:
#
# * Redirects are automatically followed
# * Referers are automatically spoofed
# * Cookies are automatically remembered
# * Requests can be cached
# * Rate limited requests can be automatically retried
# * HTML and XML responses are automatically parsed
# * Sites using Cloudflare Polish are automatically bypassed
# * SSRF attempts are blocked
#
# @example
# http = Danbooru::Http.new
# response = http.get("https://danbooru.donmai.us/posts.json")
# json = response.parse
#
module Danbooru
class Http
class Error < StandardError; end
class DownloadError < Error; end
class FileTooLargeError < Error; end
DEFAULT_TIMEOUT = 20
MAX_REDIRECTS = 5
attr_accessor :max_size, :http
class << self
delegate :get, :head, :put, :post, :delete, :cache, :follow, :max_size, :timeout, :auth, :basic_auth, :headers, :cookies, :use, :proxy, :public_only, :with_legacy_ssl, :download_media, to: :new
end
# The default HTTP client.
def self.default
Danbooru::Http::ApplicationClient.new
.timeout(DEFAULT_TIMEOUT)
.headers("Accept-Encoding": "gzip")
.use(:auto_inflate)
.use(redirector: { max_redirects: MAX_REDIRECTS })
.use(:session)
end
# The default HTTP client for requests to external websites. This includes API calls to external services, fetching source data, and downloading images.
def self.external
if Danbooru.config.http_proxy.present?
# XXX The `proxy` option is incompatible with the `public_only` option. When using a proxy, the proxy itself
# should be configured to block HTTP requests to IPs on the local network.
new.proxy.headers("User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0")
else
new.public_only.headers("User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0")
end
end
# The default HTTP client for API calls to internal services controlled by Danbooru.
def self.internal
new.headers("User-Agent": "#{Danbooru.config.canonical_app_name}/#{Rails.application.config.x.git_hash}")
end
def initialize
@http ||= Danbooru::Http.default
end
def get(url, **options)
request(:get, url, **options)
end
def head(url, **options)
request(:head, url, **options)
end
def put(url, **options)
request(:put, url, **options)
end
def post(url, **options)
request(:post, url, **options)
end
def delete(url, **options)
request(:delete, url, **options)
end
def get!(url, **options)
request!(:get, url, **options)
end
def post!(url, **options)
request!(:post, url, **options)
end
def follow(*args)
dup.tap { |o| o.http = o.http.follow(*args) }
end
def max_size(size)
dup.tap { |o| o.max_size = size }
end
def timeout(*args)
dup.tap { |o| o.http = o.http.timeout(*args) }
end
def auth(*args)
dup.tap { |o| o.http = o.http.auth(*args) }
end
def basic_auth(*args)
dup.tap { |o| o.http = o.http.basic_auth(*args) }
end
def headers(*args)
dup.tap { |o| o.http = o.http.headers(*args) }
end
def cookies(*args)
dup.tap { |o| o.http = o.http.cookies(*args) }
end
def use(*args)
dup.tap { |o| o.http = o.http.use(*args) }
end
def cache(expires_in)
use(cache: { expires_in: expires_in })
end
def proxy(url: Danbooru.config.http_proxy)
return self if url.blank?
parsed_url = Danbooru::URL.parse!(url)
dup.tap do |o|
o.http = o.http.via(parsed_url.host, parsed_url.port, parsed_url.http_user, parsed_url.password)
end
end
# allow requests only to public IPs, not to local or private networks.
def public_only
dup.tap do |o|
o.http = o.http.dup.tap do |http|
http.default_options = http.default_options.with_socket_class(ValidatingSocket)
end
end
end
# allow requests to sites using unsafe legacy renegotiations (such as dic.nicovideo.jp)
# see https://github.com/openssl/openssl/commit/72d2670bd21becfa6a64bb03fa55ad82d6d0c0f3
def with_legacy_ssl
dup.tap do |o|
o.http = o.http.dup.tap do |http|
ctx = OpenSSL::SSL::SSLContext.new
ctx.options |= OpenSSL::SSL::OP_LEGACY_SERVER_CONNECT
http.default_options = http.default_options.with_ssl_context(ctx)
end
end
end
concerning :DownloadMethods do
# Download a file from `url` and return a {MediaFile}.
#
# @param url [String] the URL to download
# @param file [Danbooru::Tempfile] the file to download the URL to
# @raise [DownloadError] if the server returns a non-200 OK response
# @raise [FileTooLargeError] if the file exceeds Danbooru's maximum download size.
# @return [Array<(HTTP::Response, MediaFile)>] the HTTP response and the downloaded file
def download_media(url, file: Danbooru::Tempfile.new("danbooru-download-#{url.parameterize.truncate(96)}-", binmode: true))
response = get(url)
raise DownloadError, "#{url} failed with code #{response.status}" if response.status != 200
raise FileTooLargeError, "File size too large (size: #{response.content_length.to_i.to_formatted_s(:human_size)}; max size: #{@max_size.to_formatted_s(:human_size)})" if @max_size && response.content_length.to_i > @max_size
size = 0
response.body.each do |chunk|
size += chunk.size
raise FileTooLargeError, "File size too large (max size: #{@max_size.to_formatted_s(:human_size)})" if @max_size && size > @max_size
file.write(chunk)
end
file.rewind
[response, MediaFile.open(file)]
end
end
protected
# Perform a HTTP request for the given URL. On error, return a fake 5xx
# response so the caller doesn't have to deal with exceptions.
#
# @param method [String] the HTTP method
# @param url [String] the URL to request
# @param options [Hash] the URL parameters
# @return [HTTP::Response] the HTTP response
def request(method, url, **options)
http.send(method, url, **options)
rescue OpenSSL::SSL::SSLError
fake_response(590)
rescue ValidatingSocket::ProhibitedIpError
fake_response(591)
rescue HTTP::Redirector::TooManyRedirectsError
fake_response(596)
rescue HTTP::TimeoutError
fake_response(597)
rescue HTTP::ConnectionError
fake_response(598)
rescue HTTP::Error
fake_response(599)
end
# Perform a HTTP request for the given URL, raising an error on 4xx or 5xx
# responses.
#
# @param method [String] the HTTP method
# @param url [String] the URL to request
# @param options [Hash] the URL parameters
# @raise [Danbooru::Http::Error] if the response was a 4xx or 5xx error
# @return [HTTP::Response] the HTTP response
def request!(method, url, **options)
response = request(method, url, **options)
if response.status.in?(200..399)
response
else
raise Error, "#{method.upcase} #{url} failed (HTTP #{response.status})"
end
end
def fake_response(status)
::HTTP::Response.new(status: status, version: "1.1", body: "", request: nil)
end
end
end