docs: add remaining docs for classes in app/logical.
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
# The base class for all background jobs on Danbooru.
|
||||||
|
#
|
||||||
|
# @see https://guides.rubyonrails.org/active_job_basics.html
|
||||||
|
# @see https://github.com/collectiveidea/delayed_job
|
||||||
class ApplicationJob < ActiveJob::Base
|
class ApplicationJob < ActiveJob::Base
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority 0
|
queue_with_priority 0
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# A job that deletes a user's favorites when they delete their account.
|
||||||
class DeleteFavoritesJob < ApplicationJob
|
class DeleteFavoritesJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority 20
|
queue_with_priority 20
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# A job that deletes a post's files after it's replaced, or a preprocessed
|
||||||
|
# upload is never completed.
|
||||||
class DeletePostFilesJob < ApplicationJob
|
class DeletePostFilesJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority 20
|
queue_with_priority 20
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# A job that regenerates a post's images and IQDB when a moderator requests it.
|
||||||
class RegeneratePostJob < ApplicationJob
|
class RegeneratePostJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority 20
|
queue_with_priority 20
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# A job that performs a mass update or tag nuke operation in a bulk update
|
||||||
|
# request.
|
||||||
class TagBatchChangeJob < ApplicationJob
|
class TagBatchChangeJob < ApplicationJob
|
||||||
queue_as :bulk_update
|
queue_as :bulk_update
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# A job that performs a tag rename or alias operation in a bulk update request.
|
||||||
|
# Jobs in the `bulk_update` queue are processed sequentially.
|
||||||
class TagRenameJob < ApplicationJob
|
class TagRenameJob < ApplicationJob
|
||||||
queue_as :bulk_update
|
queue_as :bulk_update
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# A job that downloads and generates thumbnails in the background for an image
|
||||||
|
# uploaded with the upload bookmarklet.
|
||||||
class UploadPreprocessorDelayedStartJob < ApplicationJob
|
class UploadPreprocessorDelayedStartJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority(-1)
|
queue_with_priority(-1)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# A job that tries to resume a preprocessed image upload.
|
||||||
class UploadServiceDelayedStartJob < ApplicationJob
|
class UploadServiceDelayedStartJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
queue_with_priority(-1)
|
queue_with_priority(-1)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
# A concern that adds common helper methods to models that are soft deletable.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# class Post
|
||||||
|
# deletable
|
||||||
|
# end
|
||||||
|
#
|
||||||
module Deletable
|
module Deletable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ require "danbooru/http/session"
|
|||||||
require "danbooru/http/spoof_referrer"
|
require "danbooru/http/spoof_referrer"
|
||||||
require "danbooru/http/unpolish_cloudflare"
|
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
|
module Danbooru
|
||||||
class Http
|
class Http
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
@@ -30,6 +47,7 @@ module Danbooru
|
|||||||
.timeout(DEFAULT_TIMEOUT)
|
.timeout(DEFAULT_TIMEOUT)
|
||||||
.headers("Accept-Encoding" => "gzip")
|
.headers("Accept-Encoding" => "gzip")
|
||||||
.headers("User-Agent": "#{Danbooru.config.canonical_app_name}/#{Rails.application.config.x.git_hash}")
|
.headers("User-Agent": "#{Danbooru.config.canonical_app_name}/#{Rails.application.config.x.git_hash}")
|
||||||
|
#.headers("User-Agent": Danbooru.config.canonical_app_name)
|
||||||
.use(:auto_inflate)
|
.use(:auto_inflate)
|
||||||
.use(redirector: { max_redirects: MAX_REDIRECTS })
|
.use(redirector: { max_redirects: MAX_REDIRECTS })
|
||||||
.use(:session)
|
.use(:session)
|
||||||
@@ -109,6 +127,13 @@ module Danbooru
|
|||||||
end
|
end
|
||||||
|
|
||||||
concerning :DownloadMethods do
|
concerning :DownloadMethods do
|
||||||
|
# Download a file from `url` and return a {MediaFile}.
|
||||||
|
#
|
||||||
|
# @param url [String] the URL to download
|
||||||
|
# @param file [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: Tempfile.new("danbooru-download-", binmode: true))
|
def download_media(url, file: Tempfile.new("danbooru-download-", binmode: true))
|
||||||
response = get(url)
|
response = get(url)
|
||||||
|
|
||||||
@@ -129,6 +154,13 @@ module Danbooru
|
|||||||
|
|
||||||
protected
|
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)
|
def request(method, url, **options)
|
||||||
http.send(method, url, **options)
|
http.send(method, url, **options)
|
||||||
rescue OpenSSL::SSL::SSLError
|
rescue OpenSSL::SSL::SSLError
|
||||||
@@ -145,6 +177,14 @@ module Danbooru
|
|||||||
fake_response(599, "")
|
fake_response(599, "")
|
||||||
end
|
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)
|
def request!(method, url, **options)
|
||||||
response = request(method, url, **options)
|
response = request(method, url, **options)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# A HTTP::Feature that automatically retries requests that return a 429 error
|
# A HTTP::Feature that automatically retries requests that return a 429 error
|
||||||
# or a Retry-After header. Usage: `Danbooru::Http.use(:retriable).get(url)`.
|
# or a Retry-After header.
|
||||||
#
|
#
|
||||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
|
# @example
|
||||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
# Danbooru::Http.use(:retriable).get(url)
|
||||||
|
#
|
||||||
|
# @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
|
||||||
|
# @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
||||||
module Danbooru
|
module Danbooru
|
||||||
class Http
|
class Http
|
||||||
class Retriable < HTTP::Feature
|
class Retriable < HTTP::Feature
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Bypass Cloudflare Polish (https://support.cloudflare.com/hc/en-us/articles/360000607372-Using-Cloudflare-Polish-to-compress-images)
|
# Detect sites using Cloudflare Polish and bypass it by adding a random
|
||||||
|
# cache-busting URL param.
|
||||||
|
#
|
||||||
|
# @see https://support.cloudflare.com/hc/en-us/articles/360000607372-Using-Cloudflare-Polish-to-compress-images
|
||||||
module Danbooru
|
module Danbooru
|
||||||
class Http
|
class Http
|
||||||
class UnpolishCloudflare < HTTP::Feature
|
class UnpolishCloudflare < HTTP::Feature
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# A wrapper around the IPAddress gem that adds some extra utility methods.
|
# A wrapper around the IPAddress gem that adds some extra utility methods.
|
||||||
#
|
#
|
||||||
# https://github.com/ipaddress-gem/ipaddress
|
# @see https://github.com/ipaddress-gem/ipaddress
|
||||||
|
|
||||||
module Danbooru
|
module Danbooru
|
||||||
class IpAddress
|
class IpAddress
|
||||||
attr_reader :ip_address
|
attr_reader :ip_address
|
||||||
@@ -26,7 +25,7 @@ module Danbooru
|
|||||||
|
|
||||||
# If we're being reverse proxied behind Cloudflare, then Tor connections
|
# If we're being reverse proxied behind Cloudflare, then Tor connections
|
||||||
# will appear to originate from 2405:8100:8000::/48.
|
# will appear to originate from 2405:8100:8000::/48.
|
||||||
# https://blog.cloudflare.com/cloudflare-onion-service/
|
# @see https://blog.cloudflare.com/cloudflare-onion-service/
|
||||||
def is_tor?
|
def is_tor?
|
||||||
Danbooru::IpAddress.new("2405:8100:8000::/48").include?(ip_address)
|
Danbooru::IpAddress.new("2405:8100:8000::/48").include?(ip_address)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class IqdbClient
|
|||||||
post_ids = matches.map { |match| match["post_id"] }
|
post_ids = matches.map { |match| match["post_id"] }
|
||||||
posts = Post.where(id: post_ids).group_by(&:id).transform_values(&:first)
|
posts = Post.where(id: post_ids).group_by(&:id).transform_values(&:first)
|
||||||
|
|
||||||
json.map do |match|
|
matches.map do |match|
|
||||||
post = posts.fetch(match["post_id"], nil)
|
post = posts.fetch(match["post_id"], nil)
|
||||||
match.with_indifferent_access.merge(post: post) if post
|
match.with_indifferent_access.merge(post: post) if post
|
||||||
end.compact
|
end.compact
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# A MediaFile represents an image, video, or flash file. It contains methods for
|
||||||
|
# detecting the file type, for generating a preview image, for getting metadata,
|
||||||
|
# and for resizing images.
|
||||||
|
#
|
||||||
|
# A MediaFile is a wrapper around a File object, and supports all methods
|
||||||
|
# supported by a File.
|
||||||
class MediaFile
|
class MediaFile
|
||||||
extend Memoist
|
extend Memoist
|
||||||
attr_accessor :file
|
attr_accessor :file
|
||||||
@@ -5,6 +11,11 @@ class MediaFile
|
|||||||
# delegate all File methods to `file`.
|
# delegate all File methods to `file`.
|
||||||
delegate *(File.instance_methods - MediaFile.instance_methods), to: :file
|
delegate *(File.instance_methods - MediaFile.instance_methods), to: :file
|
||||||
|
|
||||||
|
# Open a file or filename and return a MediaFile object.
|
||||||
|
#
|
||||||
|
# @param file [File, String] a filename or an open File object
|
||||||
|
# @param options [Hash] extra options for the MediaFile subclass.
|
||||||
|
# @return [MediaFile] the media file
|
||||||
def self.open(file, **options)
|
def self.open(file, **options)
|
||||||
file = Kernel.open(file, "r", binmode: true) unless file.respond_to?(:read)
|
file = Kernel.open(file, "r", binmode: true) unless file.respond_to?(:read)
|
||||||
|
|
||||||
@@ -22,6 +33,9 @@ class MediaFile
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Detect a file's type based on the magic bytes in the header.
|
||||||
|
# @param [File] an open file
|
||||||
|
# @return [Symbol] the file's type
|
||||||
def self.file_ext(file)
|
def self.file_ext(file)
|
||||||
header = file.pread(16, 0)
|
header = file.pread(16, 0)
|
||||||
|
|
||||||
@@ -47,74 +61,105 @@ class MediaFile
|
|||||||
:bin
|
:bin
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if we can generate video previews.
|
||||||
def self.videos_enabled?
|
def self.videos_enabled?
|
||||||
system("ffmpeg -version > /dev/null") && system("mkvmerge --version > /dev/null")
|
system("ffmpeg -version > /dev/null") && system("mkvmerge --version > /dev/null")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Initialize a MediaFile from a regular File.
|
||||||
|
# @param file [File] the image file
|
||||||
def initialize(file, **options)
|
def initialize(file, **options)
|
||||||
@file = file
|
@file = file
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Array<(Integer, Integer)>] the width and height of the file
|
||||||
def dimensions
|
def dimensions
|
||||||
[0, 0]
|
[0, 0]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] the width of the file
|
||||||
def width
|
def width
|
||||||
dimensions.first
|
dimensions.first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] the height of the file
|
||||||
def height
|
def height
|
||||||
dimensions.second
|
dimensions.second
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [String] the MD5 hash of the file, as a hex string.
|
||||||
def md5
|
def md5
|
||||||
Digest::MD5.file(file.path).hexdigest
|
Digest::MD5.file(file.path).hexdigest
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Symbol] the detected file extension
|
||||||
def file_ext
|
def file_ext
|
||||||
MediaFile.file_ext(file)
|
MediaFile.file_ext(file)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] the size of the file in bytes
|
||||||
def file_size
|
def file_size
|
||||||
file.size
|
file.size
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is an image
|
||||||
def is_image?
|
def is_image?
|
||||||
file_ext.in?([:jpg, :png, :gif])
|
file_ext.in?([:jpg, :png, :gif])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is a video
|
||||||
def is_video?
|
def is_video?
|
||||||
file_ext.in?([:webm, :mp4])
|
file_ext.in?([:webm, :mp4])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is a Pixiv ugoira
|
||||||
def is_ugoira?
|
def is_ugoira?
|
||||||
file_ext == :zip
|
file_ext == :zip
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is a Flash file
|
||||||
def is_flash?
|
def is_flash?
|
||||||
file_ext == :swf
|
file_ext == :swf
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is corrupted in some way
|
||||||
def is_corrupt?
|
def is_corrupt?
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file is animated. Note that GIFs and PNGs may be animated.
|
||||||
def is_animated?
|
def is_animated?
|
||||||
is_video?
|
is_video?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the file has an audio track. The track may not be audible.
|
||||||
def has_audio?
|
def has_audio?
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Float] the duration of the video or animation, in seconds.
|
||||||
def duration
|
def duration
|
||||||
0.0
|
0.0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a preview of the file, sized to fit within the given width and
|
||||||
|
# height (preserving the aspect ratio).
|
||||||
|
#
|
||||||
|
# @param width [Integer] the max width of the image
|
||||||
|
# @param height [Integer] the max height of the image
|
||||||
|
# @param options [Hash] extra options when generating the preview
|
||||||
|
# @return [MediaFile] a preview file
|
||||||
def preview(width, height, **options)
|
def preview(width, height, **options)
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a cropped preview version of the file, sized to fit exactly within
|
||||||
|
# the given width and height.
|
||||||
|
#
|
||||||
|
# @param width [Integer] the width of the cropped image
|
||||||
|
# @param height [Integer] the height of the cropped image
|
||||||
|
# @param options [Hash] extra options when generating the preview
|
||||||
|
# @return [MediaFile] a cropped preview file
|
||||||
def crop(width, height, **options)
|
def crop(width, height, **options)
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# A MediaFile for a JPEG, PNG, or GIF file. Uses libvips for resizing images.
|
||||||
|
#
|
||||||
|
# @see https://github.com/libvips/ruby-vips
|
||||||
|
# @see https://libvips.github.io/libvips/API/current
|
||||||
class MediaFile::Image < MediaFile
|
class MediaFile::Image < MediaFile
|
||||||
# Taken from ArgyllCMS 2.0.0 (see also: https://ninedegreesbelow.com/photography/srgb-profile-comparison.html)
|
# Taken from ArgyllCMS 2.0.0 (see also: https://ninedegreesbelow.com/photography/srgb-profile-comparison.html)
|
||||||
SRGB_PROFILE = "#{Rails.root}/config/sRGB.icm"
|
SRGB_PROFILE = "#{Rails.root}/config/sRGB.icm"
|
||||||
@@ -31,8 +35,8 @@ class MediaFile::Image < MediaFile
|
|||||||
is_animated_gif? || is_animated_png?
|
is_animated_gif? || is_animated_png?
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://github.com/jcupitt/libvips/wiki/HOWTO----Image-shrinking
|
# @see https://github.com/jcupitt/libvips/wiki/HOWTO----Image-shrinking
|
||||||
# http://jcupitt.github.io/libvips/API/current/Using-vipsthumbnail.md.html
|
# @see http://jcupitt.github.io/libvips/API/current/Using-vipsthumbnail.md.html
|
||||||
def preview(width, height)
|
def preview(width, height)
|
||||||
output_file = Tempfile.new(["image-preview", ".jpg"])
|
output_file = Tempfile.new(["image-preview", ".jpg"])
|
||||||
resized_image = image.thumbnail_image(width, height: height, **THUMBNAIL_OPTIONS)
|
resized_image = image.thumbnail_image(width, height: height, **THUMBNAIL_OPTIONS)
|
||||||
@@ -62,6 +66,7 @@ class MediaFile::Image < MediaFile
|
|||||||
file_ext == :png && APNGInspector.new(file.path).inspect!.animated?
|
file_ext == :png && APNGInspector.new(file.path).inspect!.animated?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Vips::Image] the Vips image object for the file
|
||||||
def image
|
def image
|
||||||
Vips::Image.new_from_file(file.path, fail: true)
|
Vips::Image.new_from_file(file.path, fail: true)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
# A MediaFile for a Pixiv ugoira file.
|
||||||
|
#
|
||||||
|
# A Pixiv ugoira is an animation format that consists of a zip file containing
|
||||||
|
# JPEG or PNG images, one per frame, plus a JSON object containing the
|
||||||
|
# inter-frame delay timings. Each frame can have a different delay, therefore
|
||||||
|
# ugoiras can have a variable framerate. The frame data isn't stored inside the
|
||||||
|
# zip file, so it must be passed around separately.
|
||||||
class MediaFile::Ugoira < MediaFile
|
class MediaFile::Ugoira < MediaFile
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
attr_reader :frame_data
|
attr_reader :frame_data
|
||||||
@@ -25,6 +32,7 @@ class MediaFile::Ugoira < MediaFile
|
|||||||
preview_frame.crop(width, height)
|
preview_frame.crop(width, height)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Convert a ugoira to a webm.
|
||||||
# XXX should take width and height and resize image
|
# XXX should take width and height and resize image
|
||||||
def convert
|
def convert
|
||||||
raise NotImplementedError, "can't convert ugoira to webm: ffmpeg or mkvmerge not installed" unless self.class.videos_enabled?
|
raise NotImplementedError, "can't convert ugoira to webm: ffmpeg or mkvmerge not installed" unless self.class.videos_enabled?
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# A MediaFile for a webm or mp4 video. Uses ffmpeg to generate preview
|
||||||
|
# thumbnails.
|
||||||
|
#
|
||||||
|
# @see https://github.com/streamio/streamio-ffmpeg
|
||||||
class MediaFile::Video < MediaFile
|
class MediaFile::Video < MediaFile
|
||||||
def dimensions
|
def dimensions
|
||||||
[video.width, video.height]
|
[video.width, video.height]
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
|
# A mixin that adds a `#paginate` method to an ActiveRecord relation.
|
||||||
|
#
|
||||||
|
# There are two pagination techniques. The first is page-based (numbered):
|
||||||
|
#
|
||||||
|
# https://danbooru.donmai.us/posts?page=1
|
||||||
|
# https://danbooru.donmai.us/posts?page=2
|
||||||
|
# https://danbooru.donmai.us/posts?page=3
|
||||||
|
#
|
||||||
|
# The second is id-based (sequential):
|
||||||
|
#
|
||||||
|
# https://danbooru.donmai.us/posts?page=a1000&limit=100
|
||||||
|
# https://danbooru.donmai.us/posts?page=a1100&limit=100
|
||||||
|
# https://danbooru.donmai.us/posts?page=a1200&limit=100
|
||||||
|
#
|
||||||
|
# https://danbooru.donmai.us/posts?page=b1000&limit=100
|
||||||
|
# https://danbooru.donmai.us/posts?page=b900&limit=100
|
||||||
|
# https://danbooru.donmai.us/posts?page=b800&limit=100
|
||||||
|
#
|
||||||
|
# where a1000 means "after id 1000" and b1000 means "before id 1000".
|
||||||
|
#
|
||||||
module PaginationExtension
|
module PaginationExtension
|
||||||
class PaginationError < StandardError; end
|
class PaginationError < StandardError; end
|
||||||
|
|
||||||
attr_accessor :current_page, :records_per_page, :paginator_count, :paginator_mode, :paginator_page_limit
|
attr_accessor :current_page, :records_per_page, :paginator_count, :paginator_mode, :paginator_page_limit
|
||||||
|
|
||||||
|
# Paginate an ActiveRecord relation. Returns a relation for the given page and number of posts per page.
|
||||||
|
#
|
||||||
|
# @param page [String] the page number, or an "aNNN" or "bNNN" string
|
||||||
|
# @param limit [Integer] the number of posts per page
|
||||||
|
# @param max_limit [Integer] the maximum number of posts per page the user can view
|
||||||
|
# @param page_limit [Integer] the highest page the user can view
|
||||||
|
# @param count [Integer] the precalculated number of search results, or nil to calculate it
|
||||||
|
# @param search_count [Object] if truthy, don't calculate the number of results; assume a large number of results
|
||||||
def paginate(page, limit: nil, max_limit: 1000, page_limit: CurrentUser.user.page_limit, count: nil, search_count: nil)
|
def paginate(page, limit: nil, max_limit: 1000, page_limit: CurrentUser.user.page_limit, count: nil, search_count: nil)
|
||||||
@records_per_page = limit || Danbooru.config.posts_per_page
|
@records_per_page = limit || Danbooru.config.posts_per_page
|
||||||
@records_per_page = @records_per_page.to_i.clamp(1, max_limit)
|
@records_per_page = @records_per_page.to_i.clamp(1, max_limit)
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
require "strscan"
|
require "strscan"
|
||||||
|
|
||||||
|
# A PostQueryBuilder represents a post search. It contains all logic for parsing
|
||||||
|
# and executing searches.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# PostQueryBuilder.new("touhou rating:s").build
|
||||||
|
# #=> <set of posts>
|
||||||
|
#
|
||||||
class PostQueryBuilder
|
class PostQueryBuilder
|
||||||
extend Memoist
|
extend Memoist
|
||||||
|
|
||||||
|
# Raised when the number of tags exceeds the user's tag limit.
|
||||||
class TagLimitError < StandardError; end
|
class TagLimitError < StandardError; end
|
||||||
|
|
||||||
# How many tags a `blah*` search should match.
|
# How many tags a `blah*` search should match.
|
||||||
@@ -58,12 +66,19 @@ class PostQueryBuilder
|
|||||||
COUNT_METATAG_SYNONYMS.flat_map { |str| [str, "#{str}_asc"] } +
|
COUNT_METATAG_SYNONYMS.flat_map { |str| [str, "#{str}_asc"] } +
|
||||||
CATEGORY_COUNT_METATAGS.flat_map { |str| [str, "#{str}_asc"] }
|
CATEGORY_COUNT_METATAGS.flat_map { |str| [str, "#{str}_asc"] }
|
||||||
|
|
||||||
|
# Tags that don't count against the user's tag limit.
|
||||||
UNLIMITED_METATAGS = %w[status rating limit]
|
UNLIMITED_METATAGS = %w[status rating limit]
|
||||||
|
|
||||||
attr_reader :query_string, :current_user, :tag_limit, :safe_mode, :hide_deleted_posts
|
attr_reader :query_string, :current_user, :tag_limit, :safe_mode, :hide_deleted_posts
|
||||||
alias_method :safe_mode?, :safe_mode
|
alias_method :safe_mode?, :safe_mode
|
||||||
alias_method :hide_deleted_posts?, :hide_deleted_posts
|
alias_method :hide_deleted_posts?, :hide_deleted_posts
|
||||||
|
|
||||||
|
# Initialize a post query.
|
||||||
|
# @param query_string [String] the tag search
|
||||||
|
# @param current_user [User] the user performing the search
|
||||||
|
# @param tag_limit [Integer] the user's tag limit
|
||||||
|
# @param safe_mode [Boolean] whether safe mode is enabled. if true, return only rating:s posts.
|
||||||
|
# @param hide_deleted_posts [Boolean] if true, filter out status:deleted posts.
|
||||||
def initialize(query_string, current_user = User.anonymous, tag_limit: nil, safe_mode: false, hide_deleted_posts: false)
|
def initialize(query_string, current_user = User.anonymous, tag_limit: nil, safe_mode: false, hide_deleted_posts: false)
|
||||||
@query_string = query_string
|
@query_string = query_string
|
||||||
@current_user = current_user
|
@current_user = current_user
|
||||||
@@ -638,6 +653,7 @@ class PostQueryBuilder
|
|||||||
relation.find_ordered(ids)
|
relation.find_ordered(ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @raise [TagLimitError] if the number of tags exceeds the user's tag limit
|
||||||
def validate!
|
def validate!
|
||||||
tag_count = terms.count { |term| !is_unlimited_tag?(term) }
|
tag_count = terms.count { |term| !is_unlimited_tag?(term) }
|
||||||
|
|
||||||
@@ -646,11 +662,14 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the metatag doesn't count against the user's tag limit
|
||||||
def is_unlimited_tag?(term)
|
def is_unlimited_tag?(term)
|
||||||
term.type == :metatag && term.name.in?(UNLIMITED_METATAGS)
|
term.type == :metatag && term.name.in?(UNLIMITED_METATAGS)
|
||||||
end
|
end
|
||||||
|
|
||||||
concerning :ParseMethods do
|
concerning :ParseMethods do
|
||||||
|
# Parse the search into a list of search terms. A search term is a tag or a metatag.
|
||||||
|
# @return [Array<OpenStruct>] a list of terms
|
||||||
def scan_query
|
def scan_query
|
||||||
terms = []
|
terms = []
|
||||||
query = query_string.to_s.gsub(/[[:space:]]/, " ")
|
query = query_string.to_s.gsub(/[[:space:]]/, " ")
|
||||||
@@ -686,6 +705,9 @@ class PostQueryBuilder
|
|||||||
terms
|
terms
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse a single-quoted, double-quoted, or unquoted string. Used for parsing metatag values.
|
||||||
|
# @param scanner [StringScanner] the current parser state
|
||||||
|
# @return [Array<(String, Boolean)>] the string and whether it was quoted
|
||||||
def scan_string(scanner)
|
def scan_string(scanner)
|
||||||
if scanner.scan(/"((?:\\"|[^"])*)"/)
|
if scanner.scan(/"((?:\\"|[^"])*)"/)
|
||||||
value = scanner.captures.first.gsub(/\\(.)/) { $1 }
|
value = scanner.captures.first.gsub(/\\(.)/) { $1 }
|
||||||
@@ -702,6 +724,9 @@ class PostQueryBuilder
|
|||||||
[value, quoted]
|
[value, quoted]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Split the search query into a list of strings, one per search term.
|
||||||
|
# Roughly the same as splitting on spaces, but accounts for quoted strings.
|
||||||
|
# @return [Array<String>] the list of terms
|
||||||
def split_query
|
def split_query
|
||||||
terms.map do |term|
|
terms.map do |term|
|
||||||
type, name, value = term.type, term.name, term.value
|
type, name, value = term.type, term.name, term.value
|
||||||
@@ -724,10 +749,16 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse a tag edit string into a list of strings, one per search term.
|
||||||
|
# @return [Array<String>] the list of terms
|
||||||
def parse_tag_edit
|
def parse_tag_edit
|
||||||
split_query
|
split_query
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse a simple string value into a Ruby type.
|
||||||
|
# @param object [String] the value to parse
|
||||||
|
# @param type [Symbol] the value's type
|
||||||
|
# @return [Object] the parsed value
|
||||||
def parse_cast(object, type)
|
def parse_cast(object, type)
|
||||||
case type
|
case type
|
||||||
when :enum
|
when :enum
|
||||||
@@ -790,6 +821,9 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse a metatag range value of the given type. For example: 1..10.
|
||||||
|
# @param string [String] the metatag value
|
||||||
|
# @param type [Symbol] the value's type
|
||||||
def parse_range(string, type)
|
def parse_range(string, type)
|
||||||
range = case string
|
range = case string
|
||||||
when /\A(.+?)\.\.\.(.+)/ # A...B
|
when /\A(.+?)\.\.\.(.+)/ # A...B
|
||||||
@@ -846,6 +880,14 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
concerning :CountMethods do
|
concerning :CountMethods do
|
||||||
|
# Return an estimate of the number of posts returned by the search. By
|
||||||
|
# default, we try to use an estimated or cached count before doing an exact
|
||||||
|
# count.
|
||||||
|
#
|
||||||
|
# @param timeout [Integer] the database timeout
|
||||||
|
# @param estimate_count [Boolean] if true, estimate the count with inexact methods
|
||||||
|
# @param skip_cache [Boolean] if true, don't use the cached count
|
||||||
|
# @return [Integer, nil] the number of posts, or nil on timeout
|
||||||
def fast_count(timeout: 1_000, estimate_count: true, skip_cache: false)
|
def fast_count(timeout: 1_000, estimate_count: true, skip_cache: false)
|
||||||
count = nil
|
count = nil
|
||||||
count = estimated_count if estimate_count
|
count = estimated_count if estimate_count
|
||||||
@@ -864,6 +906,7 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Estimate the count by parsing the Postgres EXPLAIN output.
|
||||||
def estimated_row_count
|
def estimated_row_count
|
||||||
ExplainParser.new(build).row_count
|
ExplainParser.new(build).row_count
|
||||||
end
|
end
|
||||||
@@ -896,6 +939,8 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search depends on the current user because
|
||||||
|
# of permissions or privacy settings.
|
||||||
def is_user_dependent_search?
|
def is_user_dependent_search?
|
||||||
metatags.any? do |metatag|
|
metatags.any? do |metatag|
|
||||||
metatag.name.in?(%w[upvoter upvote downvoter downvote search flagger fav ordfav favgroup ordfavgroup]) ||
|
metatag.name.in?(%w[upvoter upvote downvoter downvote search flagger fav ordfav favgroup ordfavgroup]) ||
|
||||||
@@ -906,6 +951,8 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
concerning :NormalizationMethods do
|
concerning :NormalizationMethods do
|
||||||
|
# Normalize a search by sorting tags and applying aliases.
|
||||||
|
# @return [PostQueryBuilder] the normalized query
|
||||||
def normalized_query(implicit: true, sort: true)
|
def normalized_query(implicit: true, sort: true)
|
||||||
post_query = dup
|
post_query = dup
|
||||||
post_query.terms.concat(implicit_metatags) if implicit
|
post_query.terms.concat(implicit_metatags) if implicit
|
||||||
@@ -914,6 +961,7 @@ class PostQueryBuilder
|
|||||||
post_query
|
post_query
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Apply aliases to all tags in the query.
|
||||||
def normalize_aliases!
|
def normalize_aliases!
|
||||||
tag_names = tags.map(&:name)
|
tag_names = tags.map(&:name)
|
||||||
tag_aliases = tag_names.zip(TagAlias.to_aliased(tag_names)).to_h
|
tag_aliases = tag_names.zip(TagAlias.to_aliased(tag_names)).to_h
|
||||||
@@ -924,10 +972,14 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Normalize the tag order.
|
||||||
def normalize_order!
|
def normalize_order!
|
||||||
terms.sort_by!(&:to_s).uniq!
|
terms.sort_by!(&:to_s).uniq!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Implicit metatags are metatags added by the user's account settings.
|
||||||
|
# rating:s is implicit under safe mode. -status:deleted is implicit when the
|
||||||
|
# "hide deleted posts" setting is on.
|
||||||
def implicit_metatags
|
def implicit_metatags
|
||||||
metatags = []
|
metatags = []
|
||||||
metatags << OpenStruct.new(type: :metatag, name: "rating", value: "s") if safe_mode?
|
metatags << OpenStruct.new(type: :metatag, name: "rating", value: "s") if safe_mode?
|
||||||
@@ -947,34 +999,42 @@ class PostQueryBuilder
|
|||||||
split_query.join(" ")
|
split_query.join(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The list of search terms. This includes regular tags and metatags.
|
||||||
def terms
|
def terms
|
||||||
@terms ||= scan_query
|
@terms ||= scan_query
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The list of regular tags in the search.
|
||||||
def tags
|
def tags
|
||||||
terms.select { |term| term.type == :tag }
|
terms.select { |term| term.type == :tag }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The list of metatags in the search.
|
||||||
def metatags
|
def metatags
|
||||||
terms.select { |term| term.type == :metatag }
|
terms.select { |term| term.type == :metatag }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Find all metatags with the given names.
|
||||||
def select_metatags(*names)
|
def select_metatags(*names)
|
||||||
metatags.select { |term| term.name.in?(names.map(&:to_s)) }
|
metatags.select { |term| term.name.in?(names.map(&:to_s)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Find the first metatag with any of the given names.
|
||||||
def find_metatag(*metatags)
|
def find_metatag(*metatags)
|
||||||
select_metatags(*metatags).first.try(:value)
|
select_metatags(*metatags).first.try(:value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search has a metatag with any of the given names.
|
||||||
def has_metatag?(*metatag_names)
|
def has_metatag?(*metatag_names)
|
||||||
metatags.any? { |term| term.name.in?(metatag_names.map(&:to_s).map(&:downcase)) }
|
metatags.any? { |term| term.name.in?(metatag_names.map(&:to_s).map(&:downcase)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search has a single regular tag, with any number of metatags.
|
||||||
def has_single_tag?
|
def has_single_tag?
|
||||||
tags.size == 1 && !tags.first.wildcard
|
tags.size == 1 && !tags.first.wildcard
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search is a single metatag search for the given metatag.
|
||||||
def is_metatag?(name, value = nil)
|
def is_metatag?(name, value = nil)
|
||||||
if value.nil?
|
if value.nil?
|
||||||
is_single_term? && has_metatag?(name)
|
is_single_term? && has_metatag?(name)
|
||||||
@@ -983,27 +1043,33 @@ class PostQueryBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search doesn't have any tags or metatags.
|
||||||
def is_empty_search?
|
def is_empty_search?
|
||||||
terms.size == 0
|
terms.size == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search consists of a single tag or metatag.
|
||||||
def is_single_term?
|
def is_single_term?
|
||||||
terms.size == 1
|
terms.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search has a single tag, possibly with wildcards or negation.
|
||||||
def is_single_tag?
|
def is_single_tag?
|
||||||
is_single_term? && tags.size == 1
|
is_single_term? && tags.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search has a single tag, without any wildcards or operators.
|
||||||
def is_simple_tag?
|
def is_simple_tag?
|
||||||
tag = tags.first
|
tag = tags.first
|
||||||
is_single_tag? && !tag.negated && !tag.optional && !tag.wildcard
|
is_single_tag? && !tag.negated && !tag.optional && !tag.wildcard
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the search has a single tag with a wildcard
|
||||||
def is_wildcard_search?
|
def is_wildcard_search?
|
||||||
is_single_tag? && tags.first.wildcard
|
is_single_tag? && tags.first.wildcard
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Tag, nil] the tag if the search is for a simple tag, otherwise nil
|
||||||
def simple_tag
|
def simple_tag
|
||||||
return nil if !is_simple_tag?
|
return nil if !is_simple_tag?
|
||||||
Tag.find_by_name(tags.first.name)
|
Tag.find_by_name(tags.first.name)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# A PostSearchContext handles navigating to the next or previous post in a
|
||||||
|
# search. This is used in the search navbar above or below posts.
|
||||||
|
#
|
||||||
|
# @see PostNavbarComponent
|
||||||
|
# @see PostsController#show_seq
|
||||||
class PostSearchContext
|
class PostSearchContext
|
||||||
extend Memoist
|
extend Memoist
|
||||||
attr_reader :id, :seq, :tags
|
attr_reader :id, :seq, :tags
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# A PostSet is a set of posts returned by a search. This contains helper
|
||||||
|
# methods used on the post index page.
|
||||||
|
#
|
||||||
|
# @see PostsController#index
|
||||||
module PostSets
|
module PostSets
|
||||||
class Post
|
class Post
|
||||||
MAX_PER_PAGE = 200
|
MAX_PER_PAGE = 200
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
# A RateLimiter handles HTTP rate limits for controller actions. Rate limits are
|
||||||
|
# based on the user, the user's IP, and the controller action.
|
||||||
|
#
|
||||||
|
# A RateLimiter is backed by RateLimit objects stored in the database, which
|
||||||
|
# track the rate limits with a token bucket algorithm. A RateLimiter object
|
||||||
|
# usually has two RateLimits, one for the current user and one for their IP.
|
||||||
|
#
|
||||||
|
# @see RateLimit
|
||||||
|
# @see ApplicationController#check_rate_limit
|
||||||
|
# @see https://en.wikipedia.org/wiki/Token_bucket
|
||||||
class RateLimiter
|
class RateLimiter
|
||||||
class RateLimitError < StandardError; end
|
class RateLimitError < StandardError; end
|
||||||
|
|
||||||
@@ -11,6 +21,15 @@ class RateLimiter
|
|||||||
@burst = burst
|
@burst = burst
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Create a RateLimiter object for the current controller, action, user, and
|
||||||
|
# IP. A RateLimiter usually has two RateLimits, one for the user and one for
|
||||||
|
# their IP. The action is limited if either the user or their IP are limited.
|
||||||
|
#
|
||||||
|
# @param controller_name [String] the current controller
|
||||||
|
# @param action_name [String] the current controller action
|
||||||
|
# @param user [User] the current user
|
||||||
|
# @param ip_addr [String] the user's IP address
|
||||||
|
# @return [RateLimit] the rate limit for the action
|
||||||
def self.for_action(controller_name, action_name, user, ip_addr)
|
def self.for_action(controller_name, action_name, user, ip_addr)
|
||||||
action = "#{controller_name}:#{action_name}"
|
action = "#{controller_name}:#{action_name}"
|
||||||
keys = [(user.cache_key unless user.is_anonymous?), "ip/#{ip_addr.to_s}"].compact
|
keys = [(user.cache_key unless user.is_anonymous?), "ip/#{ip_addr.to_s}"].compact
|
||||||
@@ -33,10 +52,12 @@ class RateLimiter
|
|||||||
RateLimiter.new(action, keys, rate: rate, burst: burst)
|
RateLimiter.new(action, keys, rate: rate, burst: burst)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @raise [RateLimitError] if the action is limited
|
||||||
def limit!
|
def limit!
|
||||||
raise RateLimitError if limited?
|
raise RateLimitError if limited?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the action is limited for the user or their IP
|
||||||
def limited?
|
def limited?
|
||||||
rate_limits.any?(&:limited?)
|
rate_limits.any?(&:limited?)
|
||||||
end
|
end
|
||||||
@@ -46,6 +67,7 @@ class RateLimiter
|
|||||||
super(options).except("keys", "rate_limits").merge(limits: hash)
|
super(options).except("keys", "rate_limits").merge(limits: hash)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update or create the rate limits associated with this action.
|
||||||
def rate_limits
|
def rate_limits
|
||||||
@rate_limits ||= RateLimit.create_or_update!(action: action, keys: keys, cost: cost, rate: rate, burst: burst)
|
@rate_limits ||= RateLimit.create_or_update!(action: action, keys: keys, cost: cost, rate: rate, burst: burst)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
# An API client for the post recommendation service. The recommendation service
|
||||||
|
# is a separate Python microservice that generates recommended posts for users
|
||||||
|
# and posts. This client merely fetches the pre-generated recommendations from
|
||||||
|
# the service.
|
||||||
|
#
|
||||||
|
# Recommendations are generated based on user favorites using the Python
|
||||||
|
# `implicit` library.
|
||||||
|
#
|
||||||
|
# @see https://github.com/evazion/recommender
|
||||||
|
# @see https://github.com/benfred/implicit
|
||||||
|
# @see RecommendedPostsController
|
||||||
module RecommenderService
|
module RecommenderService
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
@@ -9,14 +20,23 @@ module RecommenderService
|
|||||||
Danbooru.config.recommender_server.present?
|
Danbooru.config.recommender_server.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] True if the post has recommendations. Posts without enough
|
||||||
|
# favorites aren't generated recommendations.
|
||||||
def available_for_post?(post)
|
def available_for_post?(post)
|
||||||
enabled? && post.fav_count > MIN_POST_FAVS
|
enabled? && post.fav_count > MIN_POST_FAVS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] True if the user has recommendations. Users without enough
|
||||||
|
# favorites aren't generated recommendations.
|
||||||
def available_for_user?(user)
|
def available_for_user?(user)
|
||||||
enabled? && user.favorite_count > MIN_USER_FAVS
|
enabled? && user.favorite_count > MIN_USER_FAVS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a set of recommended posts for a user.
|
||||||
|
# @param user [User] the user to get recommendations for
|
||||||
|
# @param tags [String] a tag search to filter recommendations by
|
||||||
|
# @param limit [Integer] the maximum number of recommendations to get
|
||||||
|
# @return [Hash] The recommended posts. A hash with `score` and `post` keys.
|
||||||
def recommend_for_user(user, tags: nil, limit: 50)
|
def recommend_for_user(user, tags: nil, limit: 50)
|
||||||
response = Danbooru::Http.cache(CACHE_LIFETIME).get("#{Danbooru.config.recommender_server}/recommend/#{user.id}", params: { limit: limit })
|
response = Danbooru::Http.cache(CACHE_LIFETIME).get("#{Danbooru.config.recommender_server}/recommend/#{user.id}", params: { limit: limit })
|
||||||
return [] if response.status != 200
|
return [] if response.status != 200
|
||||||
@@ -24,6 +44,11 @@ module RecommenderService
|
|||||||
process_recs(response.parse, tags: tags, uploader: user, favoriter: user)
|
process_recs(response.parse, tags: tags, uploader: user, favoriter: user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a set of recommended posts for a post.
|
||||||
|
# @param post [Post] the post to get recommendations for
|
||||||
|
# @param tags [String] a tag search to filter recommendations by
|
||||||
|
# @param limit [Integer] the maximum number of recommendations to get
|
||||||
|
# @return [Hash] The recommended posts. A hash with `score` and `post` keys.
|
||||||
def recommend_for_post(post, tags: nil, limit: 50)
|
def recommend_for_post(post, tags: nil, limit: 50)
|
||||||
response = Danbooru::Http.cache(CACHE_LIFETIME).get("#{Danbooru.config.recommender_server}/similar/#{post.id}", params: { limit: limit })
|
response = Danbooru::Http.cache(CACHE_LIFETIME).get("#{Danbooru.config.recommender_server}/similar/#{post.id}", params: { limit: limit })
|
||||||
return [] if response.status != 200
|
return [] if response.status != 200
|
||||||
@@ -31,6 +56,8 @@ module RecommenderService
|
|||||||
process_recs(response.parse, post: post, tags: tags)
|
process_recs(response.parse, post: post, tags: tags)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Process a set of recommendations to filter out posts the user uploaded
|
||||||
|
# themselves, or has already favorited, or that don't match a tag search.
|
||||||
def process_recs(recs, post: nil, uploader: nil, favoriter: nil, tags: nil)
|
def process_recs(recs, post: nil, uploader: nil, favoriter: nil, tags: nil)
|
||||||
posts = Post.where(id: recs.map(&:first))
|
posts = Post.where(id: recs.map(&:first))
|
||||||
posts = posts.where.not(id: post.id) if post
|
posts = posts.where.not(id: post.id) if post
|
||||||
@@ -44,6 +71,7 @@ module RecommenderService
|
|||||||
recs
|
recs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle the RecommendedPostsController#index method.
|
||||||
def search(params)
|
def search(params)
|
||||||
if params[:user_name].present?
|
if params[:user_name].present?
|
||||||
user = User.find_by_name(params[:user_name])
|
user = User.find_by_name(params[:user_name])
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
|
# Calculate the tags similar to a given tag. Two tags are similar if they have
|
||||||
|
# nearly the same set of posts, and nearly the same size.
|
||||||
|
#
|
||||||
|
# Similarity is calculated using cosine similarity, which is defined as the
|
||||||
|
# number of items two sets A and B have in common, divided by sqrt(||A|| * ||B||),
|
||||||
|
# where ||A|| is the size of A. The sqrt of the sizes can be thought of as a
|
||||||
|
# normalizing factor, to normalize the number of posts in common to a 0.0 - 1.0
|
||||||
|
# range.
|
||||||
|
#
|
||||||
|
# We optimize the calculation by sampling only 1000 random posts from the tag,
|
||||||
|
# calculating the number of times each tag on those posts appears, then taking
|
||||||
|
# the top 250 most frequently appearing tags as candidates for similar tags.
|
||||||
|
#
|
||||||
|
# Related tags are used for the tag sidebar when doing a search, and for the
|
||||||
|
# related tags feature when tagging a post.
|
||||||
|
#
|
||||||
|
# @see https://en.wikipedia.org/wiki/Cosine_similarity
|
||||||
module RelatedTagCalculator
|
module RelatedTagCalculator
|
||||||
|
# Return the set of tags similar to the given search.
|
||||||
|
# @param post_query [PostQueryBuilder] the search to find similar tags for.
|
||||||
|
# @param search_sample_size [Integer] the number of posts to sample from the search
|
||||||
|
# @param tag_sample_size [Integer] the number of tags to calculate similarity for
|
||||||
|
# @param category [Integer] an optional tag category, to restrict the tags to a given category.
|
||||||
|
# @return [Array<Tag>] the set of similar tags, ordered by most similar
|
||||||
def self.similar_tags_for_search(post_query, search_sample_size: 1000, tag_sample_size: 250, category: nil)
|
def self.similar_tags_for_search(post_query, search_sample_size: 1000, tag_sample_size: 250, category: nil)
|
||||||
search_count = post_query.fast_count
|
search_count = post_query.fast_count
|
||||||
return [] if search_count.nil?
|
return [] if search_count.nil?
|
||||||
@@ -15,11 +38,20 @@ module RelatedTagCalculator
|
|||||||
tags
|
tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return the set of tags most frequently appearing in the given search.
|
||||||
|
# @param post_query [PostQueryBuilder] the search to find frequent tags for.
|
||||||
|
# @param search_sample_size [Integer] the number of posts to sample from the search
|
||||||
|
# @param category [Integer] an optional tag category, to restrict the tags to a given category.
|
||||||
|
# @return [Array<Tag>] the set of frequent tags, ordered by most frequent
|
||||||
def self.frequent_tags_for_search(post_query, search_sample_size: 1000, category: nil)
|
def self.frequent_tags_for_search(post_query, search_sample_size: 1000, category: nil)
|
||||||
sample_posts = post_query.build.reorder(:md5).limit(search_sample_size)
|
sample_posts = post_query.build.reorder(:md5).limit(search_sample_size)
|
||||||
frequent_tags_for_post_relation(sample_posts, category: category)
|
frequent_tags_for_post_relation(sample_posts, category: category)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return the set of tags most frequently appearing in the given set of posts.
|
||||||
|
# @param posts [ActiveRecord::Relation<Post>] the set of posts
|
||||||
|
# @param category [Integer] an optional tag category, to restrict the tags to a given category.
|
||||||
|
# @return [Array<Tag>] the set of frequent tags, ordered by most frequent
|
||||||
def self.frequent_tags_for_post_relation(posts, category: nil)
|
def self.frequent_tags_for_post_relation(posts, category: nil)
|
||||||
tag_counts = Post.from(posts).with_unflattened_tags.group("tag").select("tag, COUNT(*) AS overlap_count")
|
tag_counts = Post.from(posts).with_unflattened_tags.group("tag").select("tag, COUNT(*) AS overlap_count")
|
||||||
|
|
||||||
@@ -31,11 +63,20 @@ module RelatedTagCalculator
|
|||||||
tags
|
tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return the set of tags most frequently appearing in the given array of posts.
|
||||||
|
# @param posts [Array<Post>] the array of posts
|
||||||
|
# @return [Array<Tag>] the set of frequent tags, ordered by most frequent
|
||||||
def self.frequent_tags_for_post_array(posts)
|
def self.frequent_tags_for_post_array(posts)
|
||||||
tags_with_counts = posts.flat_map(&:tag_array).group_by(&:itself).transform_values(&:size)
|
tags_with_counts = posts.flat_map(&:tag_array).group_by(&:itself).transform_values(&:size)
|
||||||
tags_with_counts.sort_by { |tag_name, count| [-count, tag_name] }.map(&:first)
|
tags_with_counts.sort_by { |tag_name, count| [-count, tag_name] }.map(&:first)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a cached set of tags similar to the given search.
|
||||||
|
# @param post_query [PostQueryBuilder] the search to find similar tags for.
|
||||||
|
# @param max_tags [Integer] the maximum number of tags to return
|
||||||
|
# @param search_timeout [Integer] the database timeout for the search
|
||||||
|
# @param cache_timeout [Integer] the length of time to cache the results
|
||||||
|
# @return [Array<String>] the set of similar tag names, ordered by most similar
|
||||||
def self.cached_similar_tags_for_search(post_query, max_tags, search_timeout: 2000, cache_timeout: 8.hours)
|
def self.cached_similar_tags_for_search(post_query, max_tags, search_timeout: 2000, cache_timeout: 8.hours)
|
||||||
Cache.get(cache_key(post_query), cache_timeout, race_condition_ttl: 60.seconds) do
|
Cache.get(cache_key(post_query), cache_timeout, race_condition_ttl: 60.seconds) do
|
||||||
ApplicationRecord.with_timeout(search_timeout, []) do
|
ApplicationRecord.with_timeout(search_timeout, []) do
|
||||||
@@ -44,6 +85,11 @@ module RelatedTagCalculator
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a cache key for the given search. Some searches are cached on a
|
||||||
|
# per-user basis because they depend on the current user (for example,
|
||||||
|
# searches for private favorites, favgroups, or saved searches).
|
||||||
|
# @param post_query [PostQueryBuilder] the post search
|
||||||
|
# @return [String] the cache key
|
||||||
def self.cache_key(post_query)
|
def self.cache_key(post_query)
|
||||||
if post_query.is_user_dependent_search?
|
if post_query.is_user_dependent_search?
|
||||||
"similar_tags[#{post_query.current_user.id}]:#{post_query.to_s}"
|
"similar_tags[#{post_query.current_user.id}]:#{post_query.to_s}"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Handle finding related tags by the {RelatedTagsController}. Used for finding
|
||||||
|
# related tags when tagging a post.
|
||||||
class RelatedTagQuery
|
class RelatedTagQuery
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
include ActiveModel::Serializers::Xml
|
include ActiveModel::Serializers::Xml
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# An API client for the Reportbooru service. Reportbooru tracks post view
|
||||||
|
# counts, post search counts, and missed search counts.
|
||||||
|
#
|
||||||
|
# @see https://github.com/r888888888/reportbooru
|
||||||
class ReportbooruService
|
class ReportbooruService
|
||||||
attr_reader :http, :reportbooru_server
|
attr_reader :http, :reportbooru_server
|
||||||
|
|
||||||
@@ -6,10 +10,14 @@ class ReportbooruService
|
|||||||
@http = http.timeout(1)
|
@http = http.timeout(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if Reportbooru is configured
|
||||||
def enabled?
|
def enabled?
|
||||||
reportbooru_server.present?
|
reportbooru_server.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get the list of today's top missed searches.
|
||||||
|
# @param expires_in [Integer] the length of time to cache the results
|
||||||
|
# @return [Hash<String, Integer>] a map from searches to search counts
|
||||||
def missed_search_rankings(expires_in: 1.minute)
|
def missed_search_rankings(expires_in: 1.minute)
|
||||||
return [] unless enabled?
|
return [] unless enabled?
|
||||||
|
|
||||||
@@ -20,10 +28,18 @@ class ReportbooruService
|
|||||||
body.lines.map(&:split).map { [_1, _2.to_i] }
|
body.lines.map(&:split).map { [_1, _2.to_i] }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get the list of the day's top searches.
|
||||||
|
# @param date [Date] the date to check (YYYY-MM-DD)
|
||||||
|
# @param expires_in [Integer] the length of time to cache the results
|
||||||
|
# @return [Array<Array<(String, Float)>>] a map from searches to search counts
|
||||||
def post_search_rankings(date, expires_in: 1.minute)
|
def post_search_rankings(date, expires_in: 1.minute)
|
||||||
request("#{reportbooru_server}/post_searches/rank?date=#{date}", expires_in)
|
request("#{reportbooru_server}/post_searches/rank?date=#{date}", expires_in)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get the list of the day's most viewed posts.
|
||||||
|
# @param date [Date] the date to check (YYYY-MM-DD)
|
||||||
|
# @param expires_in [Integer] the length of time to cache the results
|
||||||
|
# @return [Array<Array<(String, Float)>>] a map from post ids to view counts
|
||||||
def post_view_rankings(date, expires_in: 1.minute)
|
def post_view_rankings(date, expires_in: 1.minute)
|
||||||
request("#{reportbooru_server}/post_views/rank?date=#{date}", expires_in)
|
request("#{reportbooru_server}/post_views/rank?date=#{date}", expires_in)
|
||||||
end
|
end
|
||||||
@@ -40,6 +56,10 @@ class ReportbooruService
|
|||||||
ranking.take(limit).map { |x| Post.find(x[0]) }
|
ranking.take(limit).map { |x| Post.find(x[0]) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Send a request to Reportbooru
|
||||||
|
# @param url [String] the full Reportbooru URL
|
||||||
|
# @param expires_in [Integer] the length of time to cache the results
|
||||||
|
# @return [Object] the parsed JSON response
|
||||||
def request(url, expires_in)
|
def request(url, expires_in)
|
||||||
return [] unless enabled?
|
return [] unless enabled?
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Allow Rails URL helpers to be used outside of views.
|
# Allow Rails URL helpers to be used outside of views.
|
||||||
# Example: Routes.posts_path(tags: "touhou") => /posts?tags=touhou
|
#
|
||||||
|
# @example
|
||||||
|
# Routes.posts_path(tags: "touhou")
|
||||||
|
# => "/posts?tags=touhou"
|
||||||
|
#
|
||||||
|
# @see config/routes.rb
|
||||||
|
# @see https://guides.rubyonrails.org/routing.html
|
||||||
class Routes
|
class Routes
|
||||||
include Singleton
|
include Singleton
|
||||||
include Rails.application.routes.url_helpers
|
include Rails.application.routes.url_helpers
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Returns status information about the running server, including software
|
||||||
|
# versions, basic load info, and Redis and Postgres info.
|
||||||
|
#
|
||||||
|
# @see StatusController
|
||||||
|
# @see https://danbooru.donmai.us/status
|
||||||
class ServerStatus
|
class ServerStatus
|
||||||
extend Memoist
|
extend Memoist
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
# Loads the current user from their session cookies or API key. Used by the
|
||||||
|
# ApplicationController to set the CurrentUser global early during the HTTP
|
||||||
|
# request cycle.
|
||||||
|
#
|
||||||
|
# @see ApplicationController#set_current_user
|
||||||
|
# @see CurrentUser
|
||||||
class SessionLoader
|
class SessionLoader
|
||||||
class AuthenticationFailure < StandardError; end
|
class AuthenticationFailure < StandardError; end
|
||||||
|
|
||||||
attr_reader :session, :request, :params
|
attr_reader :session, :request, :params
|
||||||
|
|
||||||
|
# Initialize the session loader.
|
||||||
|
# @param request the HTTP request
|
||||||
def initialize(request)
|
def initialize(request)
|
||||||
@request = request
|
@request = request
|
||||||
@session = request.session
|
@session = request.session
|
||||||
@params = request.parameters
|
@params = request.parameters
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Attempt to log a user in with the given username and password. Records a
|
||||||
|
# login attempt event and returns the user if successful.
|
||||||
|
# @param name [String] the username
|
||||||
|
# @param password [String] the user's password
|
||||||
|
# @return [User, nil] the user if the password was correct, otherwise nil
|
||||||
def login(name, password)
|
def login(name, password)
|
||||||
user = User.find_by_name(name)
|
user = User.find_by_name(name)
|
||||||
|
|
||||||
@@ -30,6 +43,7 @@ class SessionLoader
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Logs the current user out. Deletes their session cookie and records a logout event.
|
||||||
def logout
|
def logout
|
||||||
session.delete(:user_id)
|
session.delete(:user_id)
|
||||||
session.delete(:last_authenticated_at)
|
session.delete(:last_authenticated_at)
|
||||||
@@ -37,6 +51,17 @@ class SessionLoader
|
|||||||
UserEvent.create_from_request!(CurrentUser.user, :logout, request)
|
UserEvent.create_from_request!(CurrentUser.user, :logout, request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sets the current user. Runs on each HTTP request. The user is set based on
|
||||||
|
# their API key, their session cookie, or the signed user id param (used when
|
||||||
|
# reseting a password from an magic email link)
|
||||||
|
#
|
||||||
|
# Also performs post-load actions, including updating the user's last login
|
||||||
|
# timestamp, their last used IP, their timezone, their database timeout, their
|
||||||
|
# country, whether safe mode is enabled, their session cookie, and unbanning
|
||||||
|
# banned users if their ban is expired.
|
||||||
|
#
|
||||||
|
# @see ApplicationController#set_current_user
|
||||||
|
# @see CurrentUser
|
||||||
def load
|
def load
|
||||||
CurrentUser.user = User.anonymous
|
CurrentUser.user = User.anonymous
|
||||||
CurrentUser.ip_addr = request.remote_ip
|
CurrentUser.ip_addr = request.remote_ip
|
||||||
@@ -61,6 +86,7 @@ class SessionLoader
|
|||||||
DanbooruLogger.add_session_attributes(request, session, CurrentUser.user)
|
DanbooruLogger.add_session_attributes(request, session, CurrentUser.user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the current request has an API key
|
||||||
def has_api_authentication?
|
def has_api_authentication?
|
||||||
request.authorization.present? || params[:login].present? || (params[:api_key].present? && params[:api_key].is_a?(String))
|
request.authorization.present? || params[:login].present? || (params[:api_key].present? && params[:api_key].is_a?(String))
|
||||||
end
|
end
|
||||||
@@ -72,6 +98,8 @@ class SessionLoader
|
|||||||
ActiveRecord::Base.connection.execute("set statement_timeout = #{timeout}")
|
ActiveRecord::Base.connection.execute("set statement_timeout = #{timeout}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sets the current API user based on either the `login` + `api_key` URL params,
|
||||||
|
# or HTTP Basic Auth.
|
||||||
def load_session_for_api
|
def load_session_for_api
|
||||||
if request.authorization
|
if request.authorization
|
||||||
authenticate_basic_auth
|
authenticate_basic_auth
|
||||||
@@ -82,6 +110,7 @@ class SessionLoader
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sets the current API user based on the HTTP Basic Auth params.
|
||||||
def authenticate_basic_auth
|
def authenticate_basic_auth
|
||||||
credentials = ::Base64.decode64(request.authorization.split(' ', 2).last || '')
|
credentials = ::Base64.decode64(request.authorization.split(' ', 2).last || '')
|
||||||
login, api_key = credentials.split(/:/, 2)
|
login, api_key = credentials.split(/:/, 2)
|
||||||
@@ -89,6 +118,12 @@ class SessionLoader
|
|||||||
authenticate_api_key(login, api_key)
|
authenticate_api_key(login, api_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sets the current user if their API key is valid.
|
||||||
|
# @param name [String] the user name
|
||||||
|
# @param key [String] the API key
|
||||||
|
# @raise AuthenticationFailure if the API key is invalid
|
||||||
|
# @raise User::PrivilegeError if the API key doesn't have the required
|
||||||
|
# permissions for this endpoint
|
||||||
def authenticate_api_key(name, key)
|
def authenticate_api_key(name, key)
|
||||||
user, api_key = User.find_by_name(name)&.authenticate_api_key(key)
|
user, api_key = User.find_by_name(name)&.authenticate_api_key(key)
|
||||||
raise AuthenticationFailure if user.blank?
|
raise AuthenticationFailure if user.blank?
|
||||||
@@ -97,12 +132,14 @@ class SessionLoader
|
|||||||
CurrentUser.user = user
|
CurrentUser.user = user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Set the current user based on the `signed_user_id` URL param. This param is used by the reset password email.
|
||||||
# XXX use rails 6.1 signed ids (https://github.com/rails/rails/blob/6-1-stable/activerecord/CHANGELOG.md)
|
# XXX use rails 6.1 signed ids (https://github.com/rails/rails/blob/6-1-stable/activerecord/CHANGELOG.md)
|
||||||
def load_param_user(signed_user_id)
|
def load_param_user(signed_user_id)
|
||||||
session[:user_id] = Danbooru::MessageVerifier.new(:login).verify(signed_user_id)
|
session[:user_id] = Danbooru::MessageVerifier.new(:login).verify(signed_user_id)
|
||||||
load_session_user
|
load_session_user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Set the current user based on the `user_id` session cookie.
|
||||||
def load_session_user
|
def load_session_user
|
||||||
user = User.find_by_id(session[:user_id])
|
user = User.find_by_id(session[:user_id])
|
||||||
CurrentUser.user = user if user
|
CurrentUser.user = user if user
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
# Calculate a diff between two sets of strings. Used to calculate diffs between
|
||||||
|
# artist URLs and between other names. Tries to compare each set to detect which
|
||||||
|
# strings were added, which were removed, and which were changed.
|
||||||
class SetDiff
|
class SetDiff
|
||||||
attr_reader :additions, :removals, :added, :removed, :changed, :unchanged
|
attr_reader :additions, :removals, :added, :removed, :changed, :unchanged
|
||||||
|
|
||||||
|
# Initialize a diff between two sets of strings.
|
||||||
|
# @param this_list [Array<String>] the new list of strings
|
||||||
|
# @param other_list [Array<String>] the old list of strings
|
||||||
def initialize(this_list, other_list)
|
def initialize(this_list, other_list)
|
||||||
this, other = this_list.to_a, other_list.to_a
|
this, other = this_list.to_a, other_list.to_a
|
||||||
|
|
||||||
@@ -10,6 +16,12 @@ class SetDiff
|
|||||||
@added, @removed, @changed = changes(additions, removals)
|
@added, @removed, @changed = changes(additions, removals)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the strings that were either completely newly added, completely
|
||||||
|
# removed, or simply updated.
|
||||||
|
# @param added [Array<String>] the strings that were added to this_list
|
||||||
|
# @param removed [Array<String>] the strings that were removed from other_list
|
||||||
|
# @return [Array<(Array<String>, Array<String>, Array<String>)>] the list of
|
||||||
|
# additions, removals, and changed strings.
|
||||||
def changes(added, removed)
|
def changes(added, removed)
|
||||||
changed = []
|
changed = []
|
||||||
|
|
||||||
@@ -24,6 +36,9 @@ class SetDiff
|
|||||||
[added, removed, changed]
|
[added, removed, changed]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Finds the strings most similar to the `candidate` string among the `candidates` set.
|
||||||
|
# @param string [String] the string to find closest matches with in the `candidates` set.
|
||||||
|
# @param candidates [Array<String>] the set of candidate strings
|
||||||
def find_similar(string, candidates, max_dissimilarity: 0.70)
|
def find_similar(string, candidates, max_dissimilarity: 0.70)
|
||||||
distance = ->(other) { ::DidYouMean::Levenshtein.distance(string, other) }
|
distance = ->(other) { ::DidYouMean::Levenshtein.distance(string, other) }
|
||||||
max_distance = string.size * max_dissimilarity
|
max_distance = string.size * max_dissimilarity
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# https://github.com/joshfrench/rakismet
|
# Detects whether a dmail, comment, or forum post seems like spam. Autobans
|
||||||
# https://akismet.com/development/api/#comment-check
|
# users who receive more than 10 spam reports in an hour. Uses the Akismet spam
|
||||||
|
# detection service.
|
||||||
|
#
|
||||||
|
# @see https://github.com/joshfrench/rakismet
|
||||||
|
# @see https://akismet.com/development/api/#comment-check
|
||||||
class SpamDetector
|
class SpamDetector
|
||||||
include Rakismet::Model
|
include Rakismet::Model
|
||||||
|
|
||||||
# if a person receives more than 10 automatic spam reports within a 1 hour
|
# If a person receives more than 10 automatic spam reports within a 1 hour
|
||||||
# window, automatically ban them forever.
|
# window, automatically ban them forever.
|
||||||
AUTOBAN_THRESHOLD = 10
|
AUTOBAN_THRESHOLD = 10
|
||||||
AUTOBAN_WINDOW = 1.hour
|
AUTOBAN_WINDOW = 1.hour
|
||||||
@@ -12,6 +15,7 @@ class SpamDetector
|
|||||||
|
|
||||||
attr_accessor :record, :user, :user_ip, :content, :comment_type
|
attr_accessor :record, :user, :user_ip, :content, :comment_type
|
||||||
|
|
||||||
|
# The attributes to pass to Akismet
|
||||||
rakismet_attrs author: proc { user.name },
|
rakismet_attrs author: proc { user.name },
|
||||||
author_email: proc { user.email_address&.address },
|
author_email: proc { user.email_address&.address },
|
||||||
blog_lang: "en",
|
blog_lang: "en",
|
||||||
@@ -20,17 +24,23 @@ class SpamDetector
|
|||||||
content: :content,
|
content: :content,
|
||||||
user_ip: :user_ip
|
user_ip: :user_ip
|
||||||
|
|
||||||
|
# @return [Boolean] true if the Akismet API keys are configured
|
||||||
def self.enabled?
|
def self.enabled?
|
||||||
Danbooru.config.rakismet_key.present? && Danbooru.config.rakismet_url.present? && !Rails.env.test?
|
Danbooru.config.rakismet_key.present? && Danbooru.config.rakismet_url.present? && !Rails.env.test?
|
||||||
end
|
end
|
||||||
|
|
||||||
# rakismet raises an exception if the api key or url aren't configured
|
# @return [Boolean] true if the Akismet API keys are valid. Rakismet raises
|
||||||
|
# an exception if the API key or URL aren't configured
|
||||||
def self.working?
|
def self.working?
|
||||||
Rakismet.validate_key
|
Rakismet.validate_key
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if the user seems like a spammer and should be banned. Checks if they
|
||||||
|
# have received more than 10 automatic spam reports in the last hour.
|
||||||
|
# @param user [User] the user to check
|
||||||
|
# @return [Boolean] true if the user should be autobanned
|
||||||
def self.is_spammer?(user)
|
def self.is_spammer?(user)
|
||||||
return false if user.is_gold?
|
return false if user.is_gold?
|
||||||
|
|
||||||
@@ -44,10 +54,15 @@ class SpamDetector
|
|||||||
report_count >= AUTOBAN_THRESHOLD
|
report_count >= AUTOBAN_THRESHOLD
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Autobans a user.
|
||||||
|
# @param spammer [User] the user to ban
|
||||||
def self.ban_spammer!(spammer)
|
def self.ban_spammer!(spammer)
|
||||||
spammer.bans.create!(banner: User.system, reason: "Spambot.", duration: AUTOBAN_DURATION)
|
spammer.bans.create!(banner: User.system, reason: "Spambot.", duration: AUTOBAN_DURATION)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Initialize a spam check for a message.
|
||||||
|
# @param record [Dmail, ForumPost, Comment] the message to spam check
|
||||||
|
# @param user_ip [String] the IP address of the user who posted the message
|
||||||
def initialize(record, user_ip: nil)
|
def initialize(record, user_ip: nil)
|
||||||
case record
|
case record
|
||||||
when Dmail
|
when Dmail
|
||||||
@@ -73,6 +88,9 @@ class SpamDetector
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if a message seems like spam. Gold users and users who have an account
|
||||||
|
# more than a month old aren't checked to reduce the number of API calls.
|
||||||
|
# @return [Boolean] true if the message is spam
|
||||||
def spam?
|
def spam?
|
||||||
return false if !SpamDetector.enabled?
|
return false if !SpamDetector.enabled?
|
||||||
return false if user.is_gold?
|
return false if user.is_gold?
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
|
# A wrapper for the Amazon SQS API. Used by the PostArchive and PoolArchive
|
||||||
|
# service to record post and pool versions.
|
||||||
|
#
|
||||||
|
# @see https://docs.aws.amazon.com/sqs/index.html
|
||||||
|
# @see https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SQS/Client.html
|
||||||
class SqsService
|
class SqsService
|
||||||
attr_reader :url
|
attr_reader :url
|
||||||
|
|
||||||
|
# @param url [String] the URL of the Amazon SQS queue
|
||||||
def initialize(url)
|
def initialize(url)
|
||||||
@url = url
|
@url = url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the SQS service is configured
|
||||||
def enabled?
|
def enabled?
|
||||||
Danbooru.config.aws_credentials.set? && url.present?
|
Danbooru.config.aws_credentials.set? && url.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sends a message to the Amazon SQS queue.
|
||||||
|
# @param string [String] the message to send
|
||||||
|
# @param options [Hash] extra options for the SQS call
|
||||||
|
# @see https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SQS/Client.html#send_message-instance_method
|
||||||
def send_message(string, options = {})
|
def send_message(string, options = {})
|
||||||
return unless enabled?
|
return unless enabled?
|
||||||
|
|
||||||
@@ -22,6 +33,7 @@ class SqsService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# @return [Aws::SQS::Client] the SQS API client object
|
||||||
def sqs
|
def sqs
|
||||||
@sqs ||= Aws::SQS::Client.new(
|
@sqs ||= Aws::SQS::Client.new(
|
||||||
credentials: Danbooru.config.aws_credentials,
|
credentials: Danbooru.config.aws_credentials,
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
|
# StorageManager is an abstract superclass that defines a simple interface for
|
||||||
|
# storing files on local or remote backends. All image files stored by Danbooru
|
||||||
|
# are handled by a StorageManager.
|
||||||
|
#
|
||||||
|
# A StorageManager has methods for saving, deleting, and opening files, and for
|
||||||
|
# generates URLs for images.
|
||||||
|
#
|
||||||
|
# @abstract
|
||||||
|
# @see StorageManager::Local
|
||||||
|
# @see StorageManager::SFTP
|
||||||
class StorageManager
|
class StorageManager
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
attr_reader :base_url, :base_dir, :tagged_filenames
|
attr_reader :base_url, :base_dir, :tagged_filenames
|
||||||
|
|
||||||
|
# Initialize a storage manager object.
|
||||||
|
# @param base_url [String] the base URL where images are stored (ex: "https://cdn.donmai.us/")
|
||||||
|
# @param base_dir [String] the base directory where images are stored (ex: "/var/www/danbooru/public/images")
|
||||||
|
# @param tagged_filenames [Boolean] whether image URLs can include tags
|
||||||
def initialize(base_url:, base_dir:, tagged_filenames: Danbooru.config.enable_seo_post_urls)
|
def initialize(base_url:, base_dir:, tagged_filenames: Danbooru.config.enable_seo_post_urls)
|
||||||
@base_url = base_url.chomp("/")
|
@base_url = base_url.chomp("/")
|
||||||
@base_dir = base_dir
|
@base_dir = base_dir
|
||||||
@@ -13,33 +27,57 @@ class StorageManager
|
|||||||
# location it should be overwritten atomically. Either the file is fully
|
# location it should be overwritten atomically. Either the file is fully
|
||||||
# written, or an error is raised and the original file is left unchanged. The
|
# written, or an error is raised and the original file is left unchanged. The
|
||||||
# file should never be in a partially written state.
|
# file should never be in a partially written state.
|
||||||
|
#
|
||||||
|
# @param io [IO] a file (or a readable IO object)
|
||||||
|
# @param path [String] the remote path where the file should be stored
|
||||||
def store(io, path)
|
def store(io, path)
|
||||||
raise NotImplementedError, "store not implemented"
|
raise NotImplementedError, "store not implemented"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Delete the file at the given path. If the file doesn't exist, no error
|
# Delete the file at the given path. If the file doesn't exist, no error
|
||||||
# should be raised.
|
# should be raised.
|
||||||
|
# @param path [String] the remote path of the file to be deleted
|
||||||
def delete(path)
|
def delete(path)
|
||||||
raise NotImplementedError, "delete not implemented"
|
raise NotImplementedError, "delete not implemented"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Return a readonly copy of the file located at the given path.
|
# Return a readonly copy of the file located at the given path.
|
||||||
|
# @param path [String] the remote path of the file to open
|
||||||
|
# @return [MediaFile] the image file
|
||||||
def open(path)
|
def open(path)
|
||||||
raise NotImplementedError, "open not implemented"
|
raise NotImplementedError, "open not implemented"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Store or replace the given file belonging to the given post.
|
||||||
|
# @param io [IO] the file to store
|
||||||
|
# @param post [Post] the post the image belongs to
|
||||||
|
# @param type [Symbol] the image variant to store (:preview, :crop, :large, :original)
|
||||||
def store_file(io, post, type)
|
def store_file(io, post, type)
|
||||||
store(io, file_path(post.md5, post.file_ext, type))
|
store(io, file_path(post.md5, post.file_ext, type))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Delete the file belonging to the given post.
|
||||||
|
# @param post_id [Integer] the post's id
|
||||||
|
# @param md5 [String] the post's md5
|
||||||
|
# @param file_ext [String] the post's file extension
|
||||||
|
# @param type [Symbol] the image variant to delete (:preview, :crop, :large, :original)
|
||||||
def delete_file(post_id, md5, file_ext, type)
|
def delete_file(post_id, md5, file_ext, type)
|
||||||
delete(file_path(md5, file_ext, type))
|
delete(file_path(md5, file_ext, type))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return a readonly copy of the image belonging to the given post.
|
||||||
|
# @param post [Post] the post
|
||||||
|
# @param type [Symbol] the image variant to open (:preview, :crop, :large, :original)
|
||||||
|
# @return [MediaFile] the image file
|
||||||
def open_file(post, type)
|
def open_file(post, type)
|
||||||
self.open(file_path(post.md5, post.file_ext, type))
|
self.open(file_path(post.md5, post.file_ext, type))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generate the image URL for the given post.
|
||||||
|
# @param post [Post] the post
|
||||||
|
# @param type [Symbol] the post's image variant (:preview, :crop, :large, :original)
|
||||||
|
# @param tagged_filename [Boolean] whether the URL should contain the post's tags
|
||||||
|
# @return [String] the image URL
|
||||||
def file_url(post, type, tagged_filenames: false)
|
def file_url(post, type, tagged_filenames: false)
|
||||||
subdir = subdir_for(post.md5)
|
subdir = subdir_for(post.md5)
|
||||||
file = file_name(post.md5, post.file_ext, type)
|
file = file_name(post.md5, post.file_ext, type)
|
||||||
@@ -100,6 +138,7 @@ class StorageManager
|
|||||||
"#{md5[0..1]}/#{md5[2..3]}/"
|
"#{md5[0..1]}/#{md5[2..3]}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generate the tags in the image URL.
|
||||||
def seo_tags(post)
|
def seo_tags(post)
|
||||||
return "" if !tagged_filenames
|
return "" if !tagged_filenames
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# A StorageManager that stores file on remote filesystem using rclone. Rclone
|
||||||
|
# can store files on most cloud storage systems. Requires the `rclone` binary to
|
||||||
|
# be installed and configured.
|
||||||
|
#
|
||||||
|
# @see https://rclone.org/
|
||||||
class StorageManager::Rclone < StorageManager
|
class StorageManager::Rclone < StorageManager
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
attr_reader :remote, :bucket, :rclone_path, :rclone_options
|
attr_reader :remote, :bucket, :rclone_path, :rclone_options
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# A StorageManager that stores files on a remote filesystem using SFTP.
|
||||||
class StorageManager::SFTP < StorageManager
|
class StorageManager::SFTP < StorageManager
|
||||||
DEFAULT_PERMISSIONS = 0o644
|
DEFAULT_PERMISSIONS = 0o644
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
|
# A helper class for building HTML tables. Used in views.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# <%= table_for @tags do |table| %>
|
||||||
|
# <% table.column :name do |tag| %>
|
||||||
|
# <%= link_to_wiki "?", tag.name %>
|
||||||
|
# <%= link_to tag.name, posts_path(tags: tag.name) %>
|
||||||
|
# <% end %>
|
||||||
|
# <% table.column :post_count %>
|
||||||
|
# <% end %>
|
||||||
|
#
|
||||||
|
# @see app/views/table_builder/_table.html.erb
|
||||||
class TableBuilder
|
class TableBuilder
|
||||||
|
# Represents a single column in the table.
|
||||||
class Column
|
class Column
|
||||||
attr_reader :attribute, :name, :block, :header_attributes, :body_attributes
|
attr_reader :attribute, :name, :block, :header_attributes, :body_attributes
|
||||||
|
|
||||||
|
# Define a table column.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# <% table.column :post_count %>
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# <% table.column :name do |tag| %>
|
||||||
|
# <%= tag.pretty_name %>
|
||||||
|
# <% end %>
|
||||||
|
#
|
||||||
|
# @param attribute [Symbol] The attribute in the model the column is for.
|
||||||
|
# The column's name and value will come from this attribute by default.
|
||||||
|
# @param name [String] the column's name, if different from the attribute name.
|
||||||
|
# @param column [String] the column name
|
||||||
|
# @param th [Hash] the HTML attributes for the column's <th> tag.
|
||||||
|
# @param td [Hash] the HTML attributes for the column's <td> tag.
|
||||||
|
# @param width [String] the HTML width value for the <th> tag.
|
||||||
|
# @yieldparam item a block that returns the column value based on the item.
|
||||||
def initialize(attribute = nil, column: nil, th: {}, td: {}, width: nil, name: nil, &block)
|
def initialize(attribute = nil, column: nil, th: {}, td: {}, width: nil, name: nil, &block)
|
||||||
@attribute = attribute
|
@attribute = attribute
|
||||||
@column = column
|
@column = column
|
||||||
@@ -23,6 +54,11 @@ class TableBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the value of the table cell.
|
||||||
|
# @param item [ApplicationRecord] the table cell item
|
||||||
|
# @param i [Integer] the table row number
|
||||||
|
# @param j [Integer] the table column number
|
||||||
|
# @return [#to_s] the value of the table cell
|
||||||
def value(item, i, j)
|
def value(item, i, j)
|
||||||
if block.present?
|
if block.present?
|
||||||
block.call(item, i, j, self)
|
block.call(item, i, j, self)
|
||||||
@@ -37,6 +73,20 @@ class TableBuilder
|
|||||||
|
|
||||||
attr_reader :columns, :table_attributes, :row_attributes, :items
|
attr_reader :columns, :table_attributes, :row_attributes, :items
|
||||||
|
|
||||||
|
# Build a table for an array of objects, one object per row.
|
||||||
|
#
|
||||||
|
# The <table> tag is automatically given an HTML id of the form `{name}-table`.
|
||||||
|
# For example, `posts-table`, `tags-table`.
|
||||||
|
#
|
||||||
|
# The <tr> tag is automatically given an HTML id of the form `{name}-{id}`.
|
||||||
|
# For example, `post-1234`, `tag-4567`, etc. Each <tr> tag also gets a set of
|
||||||
|
# data attributes for each model; see #html_data_attributes in app/policies.
|
||||||
|
#
|
||||||
|
# @param items [Array<ApplicationRecord>] The list of ActiveRecord objects to
|
||||||
|
# build the table for. One item per table row.
|
||||||
|
# @param tr [Hash] optional HTML attributes for the <tr> tag for each row
|
||||||
|
# @param table_attributes [Hash] optional HTML attributes for the <table> tag
|
||||||
|
# @yieldparam table [self] the table being built
|
||||||
def initialize(items, tr: {}, **table_attributes)
|
def initialize(items, tr: {}, **table_attributes)
|
||||||
@items = items
|
@items = items
|
||||||
@columns = []
|
@columns = []
|
||||||
@@ -50,10 +100,17 @@ class TableBuilder
|
|||||||
yield self if block_given?
|
yield self if block_given?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add a column to the table.
|
||||||
|
# @example
|
||||||
|
# table.column(:name)
|
||||||
def column(...)
|
def column(...)
|
||||||
@columns << Column.new(...)
|
@columns << Column.new(...)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Return the HTML attributes for each <tr> tag.
|
||||||
|
# @param item [ApplicationRecord] the item for this row
|
||||||
|
# @param i [Integer] the row number (unused)
|
||||||
|
# @return [Hash] the <tr> attributes
|
||||||
def all_row_attributes(item, i)
|
def all_row_attributes(item, i)
|
||||||
return {} if !item.is_a?(ApplicationRecord)
|
return {} if !item.is_a?(ApplicationRecord)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Utility methods for working with tag categories (general, character,
|
||||||
|
# copyright, artist, meta).
|
||||||
class TagCategory
|
class TagCategory
|
||||||
module Mappings
|
module Mappings
|
||||||
# Returns a hash mapping various tag categories to a numerical value.
|
# Returns a hash mapping various tag categories to a numerical value.
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
# Rename a tag and move everything associated with it. Used when renaming or
|
||||||
|
# aliasing a tag in a bulk update request. Moves everything associated with the
|
||||||
|
# tag, including aliases, implications, *_(cosplay) and *_(style) tags, artist
|
||||||
|
# and wiki pages, saved searches, blacklists, and retags the posts themselves.
|
||||||
class TagMover
|
class TagMover
|
||||||
attr_reader :old_tag, :new_tag, :user
|
attr_reader :old_tag, :new_tag, :user
|
||||||
|
|
||||||
|
# Initalize a tag move.
|
||||||
|
# @param old_name [String] the name of the tag to move
|
||||||
|
# @param new_name [String] the new tag name
|
||||||
|
# @param user [User] the user to credit for all edits in moving the tag
|
||||||
def initialize(old_name, new_name, user: User.system)
|
def initialize(old_name, new_name, user: User.system)
|
||||||
@old_tag = Tag.find_or_create_by_name(old_name)
|
@old_tag = Tag.find_or_create_by_name(old_name)
|
||||||
@new_tag = Tag.find_or_create_by_name(new_name)
|
@new_tag = Tag.find_or_create_by_name(new_name)
|
||||||
@user = user
|
@user = user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Perform the tag move.
|
||||||
def move!
|
def move!
|
||||||
CurrentUser.scoped(user) do
|
CurrentUser.scoped(user) do
|
||||||
move_tag_category!
|
move_tag_category!
|
||||||
@@ -23,6 +32,7 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sync the category of both tags, if one is a general tag and the other is non-general.
|
||||||
def move_tag_category!
|
def move_tag_category!
|
||||||
if old_tag.general? && !new_tag.general?
|
if old_tag.general? && !new_tag.general?
|
||||||
old_tag.update!(category: new_tag.category)
|
old_tag.update!(category: new_tag.category)
|
||||||
@@ -31,6 +41,8 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Move the artist entry from the old tag to the new tag, merging it into the
|
||||||
|
# new tag's artist entry if it already has an artist entry.
|
||||||
def move_artist!
|
def move_artist!
|
||||||
return unless old_tag.artist? && old_artist.present? && !old_artist.is_deleted?
|
return unless old_tag.artist? && old_artist.present? && !old_artist.is_deleted?
|
||||||
|
|
||||||
@@ -43,6 +55,8 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Move the wiki from the old tag to the new tag, merging it into the new tag's
|
||||||
|
# wiki if it already has a wiki.
|
||||||
def move_wiki!
|
def move_wiki!
|
||||||
return unless old_wiki.present? && !old_wiki.is_deleted?
|
return unless old_wiki.present? && !old_wiki.is_deleted?
|
||||||
|
|
||||||
@@ -53,6 +67,7 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Retag the posts from the old tag to the new tag.
|
||||||
def move_posts!
|
def move_posts!
|
||||||
Post.raw_tag_match(old_tag.name).find_each do |post|
|
Post.raw_tag_match(old_tag.name).find_each do |post|
|
||||||
post.lock!
|
post.lock!
|
||||||
@@ -62,12 +77,14 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Transfer any aliases pointing to the old tag to point to the new tag.
|
||||||
def move_aliases!
|
def move_aliases!
|
||||||
old_tag.consequent_aliases.each do |tag_alias|
|
old_tag.consequent_aliases.each do |tag_alias|
|
||||||
tag_alias.update!(consequent_name: new_tag.name)
|
tag_alias.update!(consequent_name: new_tag.name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Transfer any implications from the old tag to the new tag.
|
||||||
def move_implications!
|
def move_implications!
|
||||||
old_tag.antecedent_implications.each do |tag_implication|
|
old_tag.antecedent_implications.each do |tag_implication|
|
||||||
tag_implication.update!(antecedent_name: new_tag.name)
|
tag_implication.update!(antecedent_name: new_tag.name)
|
||||||
@@ -78,6 +95,7 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Move the character's *_(cosplay) tag if it exists.
|
||||||
def move_cosplay_tag!
|
def move_cosplay_tag!
|
||||||
old_cosplay_tag = "#{old_tag.name}_(cosplay)"
|
old_cosplay_tag = "#{old_tag.name}_(cosplay)"
|
||||||
new_cosplay_tag = "#{new_tag.name}_(cosplay)"
|
new_cosplay_tag = "#{new_tag.name}_(cosplay)"
|
||||||
@@ -87,6 +105,7 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Move the artist's *_(style) tag if it exists.
|
||||||
def move_style_tag!
|
def move_style_tag!
|
||||||
old_style_tag = "#{old_tag.name}_(style)"
|
old_style_tag = "#{old_tag.name}_(style)"
|
||||||
new_style_tag = "#{new_tag.name}_(style)"
|
new_style_tag = "#{new_tag.name}_(style)"
|
||||||
@@ -96,18 +115,24 @@ class TagMover
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update all saved searches to use the new tag.
|
||||||
def move_saved_searches!
|
def move_saved_searches!
|
||||||
SavedSearch.rewrite_queries!(old_tag.name, new_tag.name)
|
SavedSearch.rewrite_queries!(old_tag.name, new_tag.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update all blacklists to use the new tag.
|
||||||
def move_blacklists!
|
def move_blacklists!
|
||||||
User.rewrite_blacklists!(old_tag.name, new_tag.name)
|
User.rewrite_blacklists!(old_tag.name, new_tag.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update any wiki pages linking to the old tag, to link to the new tag.
|
||||||
def rewrite_wiki_links!
|
def rewrite_wiki_links!
|
||||||
WikiPage.rewrite_wiki_links!(old_tag.name, new_tag.name)
|
WikiPage.rewrite_wiki_links!(old_tag.name, new_tag.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Merge two artist entries, copying everything from the old entry to the new
|
||||||
|
# one. Duplicate information will be automatically stripped when the artist
|
||||||
|
# is saved.
|
||||||
def merge_artists!
|
def merge_artists!
|
||||||
old_artist.lock!
|
old_artist.lock!
|
||||||
new_artist.lock!
|
new_artist.lock!
|
||||||
@@ -126,6 +151,7 @@ class TagMover
|
|||||||
old_artist.save!
|
old_artist.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Merge the other names from both wikis, then mark the old wiki as deleted.
|
||||||
def merge_wikis!
|
def merge_wikis!
|
||||||
old_wiki.lock!
|
old_wiki.lock!
|
||||||
new_wiki.lock!
|
new_wiki.lock!
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
|
# A simple Twitter API client that can authenticate to Twitter and fetch tweets by ID.
|
||||||
|
# @see https://developer.twitter.com/en/docs/getting-started
|
||||||
class TwitterApiClient
|
class TwitterApiClient
|
||||||
extend Memoist
|
extend Memoist
|
||||||
|
|
||||||
attr_reader :api_key, :api_secret
|
attr_reader :api_key, :api_secret
|
||||||
|
|
||||||
|
# Create a Twitter API client
|
||||||
|
# @param api_key [String] the Twitter API key
|
||||||
|
# @param api_secret [String] the Twitter API secret
|
||||||
def initialize(api_key, api_secret)
|
def initialize(api_key, api_secret)
|
||||||
@api_key, @api_secret = api_key, api_secret
|
@api_key, @api_secret = api_key, api_secret
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authenticate to Twitter with an API key and secret and receive a bearer token in response.
|
||||||
|
# @param token_expiry [Integer] the number of seconds to cache the token
|
||||||
|
# @return [String] the Twitter bearer token
|
||||||
|
# @see https://developer.twitter.com/en/docs/authentication/api-reference/token
|
||||||
def bearer_token(token_expiry = 24.hours)
|
def bearer_token(token_expiry = 24.hours)
|
||||||
http = Danbooru::Http.basic_auth(user: api_key, pass: api_secret)
|
http = Danbooru::Http.basic_auth(user: api_key, pass: api_secret)
|
||||||
response = http.cache(token_expiry).post("https://api.twitter.com/oauth2/token", form: { grant_type: :client_credentials })
|
response = http.cache(token_expiry).post("https://api.twitter.com/oauth2/token", form: { grant_type: :client_credentials })
|
||||||
response.parse["access_token"]
|
response.parse["access_token"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Danbooru::Http] the HTTP client to connect to Twitter with
|
||||||
def client
|
def client
|
||||||
Danbooru::Http.auth("Bearer #{bearer_token}")
|
Danbooru::Http.auth("Bearer #{bearer_token}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fetch a tweet by id.
|
||||||
|
# @param id [Integer] the Twitter tweet id
|
||||||
|
# @param cache [Integer] the number of seconds to cache the response
|
||||||
|
# @return [Object] the tweet
|
||||||
|
# @see https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/get-statuses-show-id
|
||||||
def status(id, cache: 1.minute)
|
def status(id, cache: 1.minute)
|
||||||
response = client.cache(cache).get("https://api.twitter.com/1.1/statuses/show.json?id=#{id}&tweet_mode=extended")
|
response = client.cache(cache).get("https://api.twitter.com/1.1/statuses/show.json?id=#{id}&tweet_mode=extended")
|
||||||
response.parse.with_indifferent_access
|
response.parse.with_indifferent_access
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
# Calculates a user's upload limit:
|
||||||
|
#
|
||||||
|
# * Each status:pending post takes up one upload slot.
|
||||||
|
# * Each status:appealed post takes up three upload slots.
|
||||||
|
# * If any of your uploads are manually deleted in under three days (status:deleted age:<3d), they take up five upload slots.
|
||||||
|
# * Slots are freed when uploads are approved or deleted.
|
||||||
|
# * You start out with 15 upload slots and can have between 5 and 40 upload slots.
|
||||||
|
# * You gain and lose slots based on approvals and deletions:
|
||||||
|
# ** You lose a slot for every 3 deletions.
|
||||||
|
# ** You gain a slot for every N approved uploads, where N depends on how many slots you already have:
|
||||||
|
# *** If you have 15 slots or less, then you gain a slot for every 10 approved uploads.
|
||||||
|
# *** If you have more than 15 slots, then you need 10 approvals, plus 2 more per upload slot over 15.
|
||||||
|
#
|
||||||
|
# Internally, upload limits are based on a point system, where you start with
|
||||||
|
# 1000 points and level up to 10,000 points. Each approval is worth 10 points
|
||||||
|
# and each deletion costs 1/3 of the points to the next level. Points are mapped
|
||||||
|
# to levels such that each level costs 20 more points (2 approvals) than the
|
||||||
|
# last. Levels are mapped to upload slots such that levels range from 0 - 35,
|
||||||
|
# and upload slots range from 5 - 40.
|
||||||
|
#
|
||||||
|
# @see https://danbooru.donmai.us/wiki_pages/about:upload_limits
|
||||||
class UploadLimit
|
class UploadLimit
|
||||||
extend Memoist
|
extend Memoist
|
||||||
|
|
||||||
@@ -8,18 +29,24 @@ class UploadLimit
|
|||||||
|
|
||||||
attr_reader :user
|
attr_reader :user
|
||||||
|
|
||||||
|
# Create an upload limit object for a user.
|
||||||
|
# @param user [User]
|
||||||
def initialize(user)
|
def initialize(user)
|
||||||
@user = user
|
@user = user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the user can't upload because they're out of upload slots.
|
||||||
def limited?
|
def limited?
|
||||||
!user.can_upload_free? && used_upload_slots >= upload_slots
|
!user.can_upload_free? && used_upload_slots >= upload_slots
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Boolean] true if the user is at max level.
|
||||||
def maxed?
|
def maxed?
|
||||||
user.upload_points >= MAXIMUM_POINTS
|
user.upload_points >= MAXIMUM_POINTS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The number of upload slots in use. Pending posts take 1
|
||||||
|
# slot, appeals take 3, and early deletions take 5.
|
||||||
def used_upload_slots
|
def used_upload_slots
|
||||||
pending_count = user.posts.pending.count
|
pending_count = user.posts.pending.count
|
||||||
appealed_count = user.post_appeals.pending.count
|
appealed_count = user.post_appeals.pending.count
|
||||||
@@ -28,26 +55,38 @@ class UploadLimit
|
|||||||
pending_count + (early_deleted_count * DELETION_COST) + (appealed_count * APPEAL_COST)
|
pending_count + (early_deleted_count * DELETION_COST) + (appealed_count * APPEAL_COST)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The number of unused upload slots, that is, the number of
|
||||||
|
# posts the user can upload.
|
||||||
def free_upload_slots
|
def free_upload_slots
|
||||||
upload_slots - used_upload_slots
|
upload_slots - used_upload_slots
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The user's total number of upload slots. Ranges from 5 to 40.
|
||||||
def upload_slots
|
def upload_slots
|
||||||
upload_level + 5
|
upload_level + 5
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The user's current upload level. Ranges from 0 to 35.
|
||||||
def upload_level
|
def upload_level
|
||||||
UploadLimit.points_to_level(user.upload_points)
|
UploadLimit.points_to_level(user.upload_points)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The number of approvals received so far on the current level.
|
||||||
def approvals_on_current_level
|
def approvals_on_current_level
|
||||||
(user.upload_points - UploadLimit.level_to_points(upload_level)) / 10
|
(user.upload_points - UploadLimit.level_to_points(upload_level)) / 10
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] The number of approvals needed to reach the next level.
|
||||||
def approvals_for_next_level
|
def approvals_for_next_level
|
||||||
UploadLimit.points_for_next_level(upload_level) / 10
|
UploadLimit.points_for_next_level(upload_level) / 10
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update the uploader's upload points when a post is approved or deleted.
|
||||||
|
# @param post [Post] The post that was approved or deleted.
|
||||||
|
# @param incremental [Boolean] True if the post was status:pending and we can
|
||||||
|
# simply increment/decrement the upload points. False if the post was an
|
||||||
|
# approval or undeletion of an old post, and we have to replay the user's
|
||||||
|
# entire upload history to recalculate their points.
|
||||||
def update_limit!(post, incremental: true)
|
def update_limit!(post, incremental: true)
|
||||||
return if user.can_upload_free?
|
return if user.can_upload_free?
|
||||||
|
|
||||||
@@ -62,6 +101,9 @@ class UploadLimit
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Recalculate the user's upload points based on replaying their entire upload history.
|
||||||
|
# @param user [User] the user
|
||||||
|
# @return [Integer] the user's upload points
|
||||||
def self.points_for_user(user)
|
def self.points_for_user(user)
|
||||||
points = INITIAL_POINTS
|
points = INITIAL_POINTS
|
||||||
|
|
||||||
@@ -76,6 +118,12 @@ class UploadLimit
|
|||||||
points
|
points
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Calculate the value of a approval or deletion. Approvals are worth a fixed
|
||||||
|
# 10 points. Deletions cost 1/3 of the points needed for the next level.
|
||||||
|
#
|
||||||
|
# @param current_points [Integer] the user's current number of upload points
|
||||||
|
# @param is_deleted [Boolean] whether the post was deleted or approved
|
||||||
|
# @return [Integer] the number of points this upload is worth
|
||||||
def self.upload_value(current_points, is_deleted)
|
def self.upload_value(current_points, is_deleted)
|
||||||
if is_deleted
|
if is_deleted
|
||||||
level = points_to_level(current_points)
|
level = points_to_level(current_points)
|
||||||
@@ -85,10 +133,17 @@ class UploadLimit
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Calculate the number of upload points needed to reach the next upload level.
|
||||||
|
# This is the number of approvals needed, times 10.
|
||||||
|
# @param level [Integer] the current upload level
|
||||||
|
# @return [Integer] The number of points needed to reach the next upload level
|
||||||
def self.points_for_next_level(level)
|
def self.points_for_next_level(level)
|
||||||
100 + 20 * [level - 10, 0].max
|
100 + 20 * [level - 10, 0].max
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Calculate the level that corresponds to a given number of upload points.
|
||||||
|
# @param [Integer] the upload points (0 - 10,000)
|
||||||
|
# @return [Integer] the upload level (0 - 35)
|
||||||
def self.points_to_level(points)
|
def self.points_to_level(points)
|
||||||
level = 0
|
level = 0
|
||||||
|
|
||||||
@@ -101,6 +156,9 @@ class UploadLimit
|
|||||||
level
|
level
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Calculate the base upload points that correspond to a given upload level.
|
||||||
|
# @param [Integer] the upload level (0 - 35)
|
||||||
|
# @return [Integer] the upload points (0 - 10,000)
|
||||||
def self.level_to_points(level)
|
def self.level_to_points(level)
|
||||||
(1..level).map do |n|
|
(1..level).map do |n|
|
||||||
points_for_next_level(n - 1)
|
points_for_next_level(n - 1)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# A service object for uploading an image.
|
||||||
class UploadService
|
class UploadService
|
||||||
attr_reader :params, :post, :upload
|
attr_reader :params, :post, :upload
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# Delete a user's account. Deleting an account really just deactivates the
|
||||||
|
# account, it doesn't fully delete the user from the database. It wipes their
|
||||||
|
# username, password, account settings, favorites, and saved searches, and logs
|
||||||
|
# the deletion.
|
||||||
class UserDeletion
|
class UserDeletion
|
||||||
include ActiveModel::Validations
|
include ActiveModel::Validations
|
||||||
|
|
||||||
@@ -5,12 +9,19 @@ class UserDeletion
|
|||||||
|
|
||||||
validate :validate_deletion
|
validate :validate_deletion
|
||||||
|
|
||||||
|
# Initialize a user deletion.
|
||||||
|
# @param user [User] the user to delete
|
||||||
|
# @param password [String] the user's password (for confirmation)
|
||||||
|
# @param request the HTTP request (for logging the deletion in the user event log)
|
||||||
def initialize(user, password, request)
|
def initialize(user, password, request)
|
||||||
@user = user
|
@user = user
|
||||||
@password = password
|
@password = password
|
||||||
@request = request
|
@request = request
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Delete the account, if the deletion is allowed.
|
||||||
|
# @return [Boolean] if the deletion failed
|
||||||
|
# @return [User] if the deletion succeeded
|
||||||
def delete!
|
def delete!
|
||||||
return false if invalid?
|
return false if invalid?
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
# Promotes the user to a higher level, or grants them approver or unlimited
|
||||||
|
# uploader privileges. Also validates that the promotion is allowed, gives the
|
||||||
|
# user a feedback, sends them a notification dmail, and creates a mod action for
|
||||||
|
# the promotion.
|
||||||
class UserPromotion
|
class UserPromotion
|
||||||
attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts
|
attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts
|
||||||
|
|
||||||
|
# Initialize a new promotion.
|
||||||
|
# @param user [User] the user to promote
|
||||||
|
# @param promoter [User] the user doing the promotion
|
||||||
|
# @param new_level [Integer] the new user level
|
||||||
|
# @param can_upload_free [Boolean] whether the user should gain unlimited upload privileges
|
||||||
|
# @param can_approve_posts [Boolean] whether the user should gain approval privileges
|
||||||
def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil)
|
def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil)
|
||||||
@user = user
|
@user = user
|
||||||
@promoter = promoter
|
@promoter = promoter
|
||||||
@@ -54,6 +64,7 @@ class UserPromotion
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Build the dmail and user feedback message.
|
||||||
def build_messages
|
def build_messages
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
# Checks whether a new account seems suspicious and should require email verification.
|
# Checks whether a new account requires verification. An account requires
|
||||||
|
# verification if the IP is a proxy, or the IP is under a partial (signup) IP
|
||||||
|
# ban, or it was used to create another account recently.
|
||||||
class UserVerifier
|
class UserVerifier
|
||||||
extend Memoist
|
extend Memoist
|
||||||
|
|
||||||
attr_reader :current_user, :request
|
attr_reader :current_user, :request
|
||||||
|
|
||||||
# current_user is the user creating the new account, not the new account itself.
|
# Create a user verifier.
|
||||||
|
# @param current_user [User] the user creating the new account, not the new account itself.
|
||||||
|
# @param request the HTTP request
|
||||||
def initialize(current_user, request)
|
def initialize(current_user, request)
|
||||||
@current_user, @request = current_user, request
|
@current_user, @request = current_user, request
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns true if the new account should be restricted. Signups from local
|
||||||
|
# IPs are unrestricted so that verification isn't required for development,
|
||||||
|
# testing, or personal boorus.
|
||||||
def requires_verification?
|
def requires_verification?
|
||||||
return false if !Danbooru.config.new_user_verification?
|
return false if !Danbooru.config.new_user_verification?
|
||||||
return false if ip_address.is_local?
|
return false if ip_address.is_local?
|
||||||
@@ -18,6 +24,7 @@ class UserVerifier
|
|||||||
is_ip_banned? || is_logged_in? || is_recent_signup? || is_proxy?
|
is_ip_banned? || is_logged_in? || is_recent_signup? || is_proxy?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [Integer] Returns whether the new account should be Restricted or a Member
|
||||||
def initial_level
|
def initial_level
|
||||||
if requires_verification?
|
if requires_verification?
|
||||||
User::Levels::RESTRICTED
|
User::Levels::RESTRICTED
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# A TCPSocket wrapper that disallows connections to local or private IPs. Used for SSRF protection.
|
# A TCPSocket wrapper that disallows connections to local or private IPs. Used
|
||||||
# https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
# by {Danbooru::Http} to prevent server-side request forgery (SSRF) attacks. For
|
||||||
|
# example, if we try to download an image from http://example.com/image.jpg, but
|
||||||
|
# example.com resolves to 127.0.0.1, then the request is prohibited.
|
||||||
|
#
|
||||||
|
# @see https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
||||||
require "resolv"
|
require "resolv"
|
||||||
|
|
||||||
class ValidatingSocket < TCPSocket
|
class ValidatingSocket < TCPSocket
|
||||||
|
|||||||
@@ -2,21 +2,25 @@ class UserMailer < ApplicationMailer
|
|||||||
helper :application
|
helper :application
|
||||||
helper :users
|
helper :users
|
||||||
|
|
||||||
|
# The email sent when a user receives a DMail.
|
||||||
def dmail_notice(dmail)
|
def dmail_notice(dmail)
|
||||||
@dmail = dmail
|
@dmail = dmail
|
||||||
mail to: dmail.to.email_with_name, subject: "#{Danbooru.config.app_name} - Message received from #{dmail.from.name}"
|
mail to: dmail.to.email_with_name, subject: "#{Danbooru.config.app_name} - Message received from #{dmail.from.name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The email sent when a user requests a password reset.
|
||||||
def password_reset(user)
|
def password_reset(user)
|
||||||
@user = user
|
@user = user
|
||||||
mail to: @user.email_with_name, subject: "#{Danbooru.config.app_name} password reset request"
|
mail to: @user.email_with_name, subject: "#{Danbooru.config.app_name} password reset request"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The email sent when a user changes their email address.
|
||||||
def email_change_confirmation(user)
|
def email_change_confirmation(user)
|
||||||
@user = user
|
@user = user
|
||||||
mail to: @user.email_with_name, subject: "Confirm your email address"
|
mail to: @user.email_with_name, subject: "Confirm your email address"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The email sent when a new user signs up with an email address.
|
||||||
def welcome_user(user)
|
def welcome_user(user)
|
||||||
@user = user
|
@user = user
|
||||||
mail to: @user.email_with_name, subject: "Welcome to #{Danbooru.config.app_name}! Confirm your email address"
|
mail to: @user.email_with_name, subject: "Welcome to #{Danbooru.config.app_name}! Confirm your email address"
|
||||||
|
|||||||
Reference in New Issue
Block a user