diff --git a/.gitignore b/.gitignore index bc45c3f..552f561 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ # Ignore master key for decrypting credentials and more. /config/master.key +/config/local_config.rb /app/assets/builds/* !/app/assets/builds/.keep diff --git a/Dockerfile b/Dockerfile index 66748c9..9f2f555 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,4 @@ COPY --from=node-builder /root/.cache/node /root/.cache/node COPY --from=node-builder /app/node_modules node_modules COPY --from=ruby-builder /usr/local/bundle /usr/local/bundle -# Stop bin/rails console from offering autocomplete -RUN echo "IRB.conf[:USE_AUTOCOMPLETE] = false" > ~/.irbrc - CMD ["foreman", "start", "-f", "Procfile.dev"] diff --git a/Gemfile b/Gemfile index 160e3fd..a0aa477 100644 --- a/Gemfile +++ b/Gemfile @@ -21,9 +21,6 @@ gem "puma", ">= 5.0" # Bundle and transpile JavaScript [https://github.com/rails/jsbundling-rails] gem "jsbundling-rails" -# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] -gem "turbo-rails" - # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" @@ -93,3 +90,11 @@ gem "ruby-vips", "~> 2.2" gem "simple_form", "~> 5.3" gem "responders", "~> 3.1" + +gem "pagy", "~> 8.3" + +gem "retriable", "~> 3.1" + +gem "addressable", "~> 2.8" + +gem "aws-sdk-s3", "~> 1.149" diff --git a/Gemfile.lock b/Gemfile.lock index f034ca2..b490686 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,6 +77,22 @@ GEM addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) + aws-eventstream (1.3.0) + aws-partitions (1.924.0) + aws-sdk-core (3.194.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.8) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.80.0) + aws-sdk-core (~> 3, >= 3.193.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.149.0) + aws-sdk-core (~> 3, >= 3.194.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) better_html (2.0.2) actionview (>= 6.0) @@ -145,6 +161,7 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + jmespath (1.6.2) jsbundling-rails (1.2.1) railties (>= 6.0.0) json (2.6.3) @@ -176,6 +193,7 @@ GEM nio4r (2.5.9) nokogiri (1.15.4-x86_64-linux) racc (~> 1.4) + pagy (8.3.0) parallel (1.23.0) parser (3.2.2.4) ast (~> 2.4.1) @@ -241,6 +259,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) + retriable (3.1.2) rexml (3.2.6) rubocop (1.57.2) json (~> 2.3) @@ -287,10 +306,6 @@ GEM stringio (3.0.8) thor (1.3.0) timeout (0.4.1) - turbo-rails (1.5.0) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) @@ -314,6 +329,8 @@ PLATFORMS x86_64-linux DEPENDENCIES + addressable (~> 2.8) + aws-sdk-s3 (~> 1.149) bootsnap capybara debug @@ -325,12 +342,14 @@ DEPENDENCIES image_optim (~> 0.31.3) jbuilder jsbundling-rails + pagy (~> 8.3) pg (~> 1.1) puma (>= 5.0) rails (~> 7.1.1) redis (~> 5.0) request_store (~> 1.5) responders (~> 3.1) + retriable (~> 3.1) rubocop (~> 1.57) rubocop-erb (~> 0.3.0) rubocop-rails (~> 2.22) @@ -340,7 +359,6 @@ DEPENDENCIES sprockets-rails stimulus-rails timeout (~> 0.4.1) - turbo-rails tzinfo-data web-console whenever (~> 1.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 30ad64a..121d2df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -196,7 +196,7 @@ class ApplicationController < ActionController::Base @backtrace = Rails.backtrace_cleaner.clean(@exception.backtrace) format = :html unless format.in?(%i[html json]) - @message = "An unexpected error occurred." if Rails.env.production? && message == exception.message + @message = "An unexpected error occurred." if !(Rails.env.development? || CurrentUser.is_admin?) && message == exception.message WebLogger.log_exception(@exception, expected: @expected) log = ExceptionLog.add(exception, request) unless @expected @@ -253,4 +253,5 @@ class ApplicationController < ActionController::Base include CommonRoutes include RenderMethods include SearchMethods + include Pagy::Backend end diff --git a/app/controllers/yiff_rest/api_v2/application_controller.rb b/app/controllers/yiff_rest/api_v2/application_controller.rb new file mode 100644 index 0000000..1662664 --- /dev/null +++ b/app/controllers/yiff_rest/api_v2/application_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module YiffRest + module APIV2 + class ApplicationController < ::YiffRest::ApplicationController + protected + + def site_domain + YiffRestRoutes::V2_DOMAIN + end + + def validate_discord + redirect_to(Websites.config.yiffyapi_discord_redirect("manage_images"), allow_other_host: true) if session[:discord_user].blank? + end + + def prepare_user + name = session.dig("discord_user", "global_name") || "#{session.dig('discord_user', 'username')}#{@session.dig('discord_user', 'discriminator')}" + CurrentUser.user = APIUser.find_or_create_by(id: session.dig("discord_user", "id")) + CurrentUser.update!(name: name, discord_data: session["discord_user"]) + CurrentUser.update_avatar(session.dig("discord_user", "avatar")) + end + end + end +end diff --git a/app/controllers/yiff_rest/api_v2/images_controller.rb b/app/controllers/yiff_rest/api_v2/images_controller.rb new file mode 100644 index 0000000..c9793d2 --- /dev/null +++ b/app/controllers/yiff_rest/api_v2/images_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module YiffRest + module APIV2 + class ImagesController < ApplicationController + before_action :validate_discord + before_action :prepare_user + before_action :admin_only + + def index + @sp = search_images_params + @pagy, @images = pagy(APIImage.search(@sp).order(created_at: :desc), size: [1, 2, 2, 1]) + end + + def new + @image = APIImage.new(create_params) + @categories = APIImage.categories + end + + def edit + @image = APIImage.find(params[:id]) + @categories = APIImage.categories + end + + def create + @service = APIImageUploadService.new(create_params) + @image = @service.start! + @categories = APIImage.categories + if @image.invalid? + flash.now[:alert] = @image.errors.full_messages.join(", ") + return render(:new) + end + redirect_to(yiff_rest_api_v2_manage_images_path(search: { category: @image.category }), notice: "Image added") + end + + def update + @image = APIImage.find(params[:id]) + @image.update(update_params) + @categories = APIImage.categories + if @image.invalid? + flash.now[:alert] = @image.errors.full_messages.join(", ") + return render(:edit) + end + redirect_to(yiff_rest_api_v2_manage_images_path(search: { category: @image.category }), notice: "Image updated") + end + + def destroy + @image = APIImage.find(params[:id]) + @image.destroy + redirect_to(yiff_rest_api_v2_manage_images_path(search: { category: @image.category }), notice: "Image deleted") + end + + private + + def load_category + @category = APIImage.categories.find { |c| c.db == params[:manage_id] } + raise(ActiveRecord::RecordNotFound) if @category.blank? + end + + def site_title + "YiffyAPI V2 - Manage Images" + end + + def search_images_params + permit_search_params(%i[category]) + end + + def create_params + params.fetch(:api_image, {}).permit(:category, :original_url, :sources_string, :artists_string, :file) + end + + def update_params + params.fetch(:api_image, {}).permit(:category, :sources_string, :artists_string) + end + end + end +end diff --git a/app/controllers/yiff_rest/api_v2/manage_controller.rb b/app/controllers/yiff_rest/api_v2/manage_controller.rb new file mode 100644 index 0000000..648cb45 --- /dev/null +++ b/app/controllers/yiff_rest/api_v2/manage_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module YiffRest + module APIV2 + class ManageController < ApplicationController + before_action :validate_discord, except: %i[logout] + before_action :prepare_user, except: %i[logout] + + def index + @categories = APIImage.categories + end + + def show + @category = APIImage.categories.find { |c| c.db == params[:id] } + raise(ActiveRecord::RecordNotFound) if @category.blank? + @count = APIImage.cached_count(params[:id]) + @pagy, @images = pagy(APIImage.where(category: params[:id]).search(search_params).order(created_at: :desc), size: [1, 2, 2, 1]) + end + + def sync + APIImage.sync_all + redirect_to(yiff_rest_api_v2_manage_index_path, notice: "Images will soon be synced.") + end + + def logout + session.delete("discord_user") + CurrentUser.user = nil + redirect_to(yiff_rest_root_path, notice: "You have been logged out.") + end + + private + + def site_title + "YiffyAPI V2 - Manage Images" + end + end + end +end diff --git a/app/controllers/yiff_rest/api_v2_controller.rb b/app/controllers/yiff_rest/api_v2_controller.rb index 84399a7..78d5376 100644 --- a/app/controllers/yiff_rest/api_v2_controller.rb +++ b/app/controllers/yiff_rest/api_v2_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module YiffRest - class APIV2Controller < ApplicationController + class APIV2Controller < APIV2::ApplicationController include ::ApplicationController::ReadonlyMethods before_action :handle_ratelimit, except: %i[robots] before_action :validate_images_access, only: %i[index] @@ -143,12 +143,8 @@ module YiffRest private - def site_domain - YiffRestRoutes::V2_DOMAIN - end - def api_categories - categories = APIImage.categories.pluck(:db) + categories = APIImage.categories.map(&:db) [*categories, "chris"] # gay little polar cutie end diff --git a/app/controllers/yiff_rest/discord_controller.rb b/app/controllers/yiff_rest/discord_controller.rb index 176aa23..9f71914 100644 --- a/app/controllers/yiff_rest/discord_controller.rb +++ b/app/controllers/yiff_rest/discord_controller.rb @@ -2,7 +2,7 @@ module YiffRest class DiscordController < ApplicationController - before_action :handle_oauth, only: %i[count_servers flags apikey] + before_action :handle_oauth, except: %i[index interactions] def index end @@ -14,10 +14,15 @@ module YiffRest end def apikey - return redirect_to("http://websites4.containers.local:3000/apikeys?domain=yiff.rest") if Rails.env.development? + return redirect_to("http://websites4.containers.local:3000/apikeys?domain=yiff.rest", allow_other_host: true) if Rails.env.development? redirect_to("https://yiff.rest/apikeys", allow_other_host: true) end + def manage_images + return redirect_to("http://websites4.containers.local:3000/manage?domain=v2.yiff.rest", allow_other_host: true) if Rails.env.development? + redirect_to("https://v2.yiff.rest/manage", allow_other_host: true) + end + def interactions begin verify_request! @@ -81,8 +86,10 @@ module YiffRest public_flags: DiscordConstants::UserFlags.parse_flags(user["public_flags"]).map(&:title), all_flags: DiscordConstants::UserFlags.parse_flags(user["flags"] - user["public_flags"]).map(&:title), } - when "apikey" + when "apikey", "manage_images" session[:discord_user] = res.get_authorization["user"].slice("id", "username", "discriminator", "global_name", "avatar") + else + raise("Unknown action: #{params[:action]}") end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 60c6bd6..e6a6d41 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module ApplicationHelper + include Pagy::Frontend + def page_title return content_for(:page_title) if content_for?(:page_title) return site_title if defined?(site_title) @@ -33,4 +35,23 @@ module ApplicationHelper return css if request.path == path "" end + + def time_tag(content, time) + datetime = time.strftime("%Y-%m-%dT%H:%M%:z") + tag.time(content || datetime, datetime: datetime, title: time.to_fs) + end + + def compact_time(time) + time_tag(time.strftime("%Y-%m-%d %H:%M:%S"), time) + end + + def error_messages_for(instance_name) + instance = instance_variable_get("@#{instance_name}") + + if instance&.errors&.any? + %(
Error: #{instance.__send__(:errors).full_messages.join(', ')}
).html_safe + else + "" + end + end end diff --git a/app/javascript/application.ts b/app/javascript/application.ts index 63a5845..89fec31 100644 --- a/app/javascript/application.ts +++ b/app/javascript/application.ts @@ -1,6 +1,5 @@ /// // Entry point for the build script in your package.json -import "@hotwired/turbo-rails"; import "bootstrap"; import "bootstrap/dist/css/bootstrap.min.css"; import jQuery from "jquery"; diff --git a/app/javascript/controllers/notice.ts b/app/javascript/controllers/notice.ts index 1d08f30..1eeccee 100644 --- a/app/javascript/controllers/notice.ts +++ b/app/javascript/controllers/notice.ts @@ -3,9 +3,21 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static override targets = ["notice"]; declare noticeTarget: HTMLElement; + timeout: number | undefined; - close(event: KeyboardEvent) { - event.preventDefault(); + // automatically close + override connect() { + this.timeout = setTimeout(() => this._close(), 3500); + } + + _close() { $(this.noticeTarget).fadeOut("fast"); } + + close(event: KeyboardEvent) { + if (this.timeout) { + clearTimeout(this.timeout); + } + event.preventDefault(); + } } diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 6ef3cb8..4750858 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -47,3 +47,7 @@ body.c-application { text-align: center; } } + +.backtrace-line { + list-style: none; +} diff --git a/app/javascript/styles/yiff.rest/home.scss b/app/javascript/styles/yiff.rest/home.scss index 461a08b..2da8bed 100644 --- a/app/javascript/styles/yiff.rest/home.scss +++ b/app/javascript/styles/yiff.rest/home.scss @@ -1,6 +1,7 @@ @import "apikeys"; @import "discord"; @import "state"; +@import "v2"; body.s-yiff-rest { background-color: #2C2F33; diff --git a/app/javascript/styles/yiff.rest/v2.scss b/app/javascript/styles/yiff.rest/v2.scss new file mode 100644 index 0000000..1a32f21 --- /dev/null +++ b/app/javascript/styles/yiff.rest/v2.scss @@ -0,0 +1,87 @@ +body.s-v2-yiff-rest { + background-color: #2c2f33; + color: #fffdd0; + + a { + color: #fff; + text-decoration: none; + } + + // Bootstrap does not have built in dark pagination + .pagy-bootstrap { + $gray-100: #f8f9fa; + $gray-600: #6c757d; + $gray-800: #343a40; + $pagination-focus-outline: 0; + $focus-ring-width: 0.25rem; + $focus-ring-opacity: 0.25; + $blue: #0d6efd; + $white: #fff; + $primary: $blue; + $focus-ring-color: rgba($primary, $focus-ring-opacity); + $focus-ring-blur: 0; + $focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width + $focus-ring-color; + $pagination-focus-box-shadow: $focus-ring-box-shadow; + + $pagination-dark-color: $gray-100; + $pagination-dark-bg: $gray-800; + $pagination-dark-border-color: $gray-600; + + $pagination-dark-hover-color: $pagination-dark-color; + $pagination-dark-hover-bg: $gray-600; + $pagination-dark-hover-border-color: $pagination-dark-border-color; + $component-active-color: $white; + $component-active-bg: $primary; + $pagination-active-color: $component-active-color; + $pagination-active-bg: $component-active-bg; + $pagination-active-border-color: $component-active-bg; + + .page-link { + color: $pagination-dark-color; + background-color: $pagination-dark-bg; + border-color: $pagination-dark-border-color; + + &:hover { + color: $pagination-dark-hover-color; + background-color: $pagination-dark-hover-bg; + border-color: $pagination-dark-hover-border-color; + } + + &:focus { + outline: $pagination-focus-outline; + box-shadow: $pagination-focus-box-shadow; + } + } + + .page-item { + &.active .page-link { + color: $pagination-active-color; + background-color: $pagination-active-bg; + border-color: $pagination-active-border-color; + } + + &.disabled .page-link { + background-color: $pagination-dark-bg; + border-color: $pagination-dark-border-color; + } + } + } + + .category-list, .category-image-list { + a { + color: #ede660; + } + + ul.details { + padding: 0; + + li { + list-style: none; + ul { + padding-left: 1rem; + } + } + } + } +} diff --git a/app/jobs/e621_thumbnail_job.rb b/app/jobs/e621_thumbnail_job.rb index 8984fd2..f06027f 100644 --- a/app/jobs/e621_thumbnail_job.rb +++ b/app/jobs/e621_thumbnail_job.rb @@ -8,11 +8,10 @@ class E621ThumbnailJob < ApplicationJob outfile = Tempfile.new(%W[e621-thumbnail-#{entry.stripped_md5} .#{entry.filetype}]) cutfile = Tempfile.new(%W[e621-thumbnail-#{entry.stripped_md5} .cut.webm]) palettefile = Tempfile.new(%W[e621-thumbnail-#{entry.stripped_md5} .palette.png]) - Timeout.timeout(1000) do + Timeout.timeout(120) do infile.binmode - HTTParty.get("https://static1.e621.net/data/#{entry.stripped_md5[0..1]}/#{entry.stripped_md5[2..3]}/#{entry.stripped_md5}.webm", stream_body: true) do |fragment| - infile.write(fragment) - end + io = FileDownload.new("https://static1.e621.net/data/#{entry.stripped_md5[0..1]}/#{entry.stripped_md5[2..3]}/#{entry.stripped_md5}.webm").download! + IO.copy_stream(io, infile) duration = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 #{infile.path}`.to_f offset = duration > 10 ? rand(0..(duration - 10)) : duration / 2 if entry.filetype == "gif" @@ -23,7 +22,7 @@ class E621ThumbnailJob < ApplicationJob `ffmpeg -y -i #{infile.path} -ss #{offset} -vframes 1 #{outfile.path} 1>&2` end ImageOptim.new.optimize_image!(outfile.path) - StorageManager::E621Thumbnails.upload("#{entry.stripped_md5}.#{entry.filetype}", outfile.read) + Websites.config.e621_thumbnails_storage.upload("/#{entry.stripped_md5}.#{entry.filetype}", outfile) entry.update!(status: "complete") execute_webhook(entry, title: "Thumbnail Generated (#{entry.filetype})") end diff --git a/app/logical/api_category.rb b/app/logical/api_category.rb new file mode 100644 index 0000000..ce10715 --- /dev/null +++ b/app/logical/api_category.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class APICategory + attr_reader :name, :db, :sfw + + SOURCES = [ + %w[e621.net E621], + %w[furaffinity.net FurAffinity], + %w[inkbunny.net InkBunny], + %w[deviantart.com DeviantArt], + %w[twitter.com Twitter], + ].freeze + + def initialize(name, db, sfw) + @name = name + @db = db + @sfw = sfw + end + + def count + APIImage.cached_count(db) + end + + def real_count + APIImage.where(category: db).count + end + + def unsourced_count + APIImage.where(category: db, sources: []).count + end + + def sources + r = APIImage.joins("CROSS JOIN unnest(sources) AS source").where(category: db).distinct + other = r + list = [] + + APICategory::SOURCES.each do |value, name| + count = r.where("source ILIKE ?", "%#{value}%").count + list << { name: name, count: count } + other = other.where("source NOT ILIKE ?", "%#{value}%") + end + list << { name: "Unsourced", count: unsourced_count } + # noinspection RubyMismatchedArgumentType + list << { name: "Other", count: other.count } + list + end + + def created_at + APIImage.where(category: db).minimum(:created_at) + end + + def updated_at + APIImage.where(category: db).maximum(:updated_at) + end + + def as_json(*) + { + name: name, + db: db, + sfw: sfw, + count: count, + } + end +end diff --git a/app/logical/api_image_upload_service.rb b/app/logical/api_image_upload_service.rb new file mode 100644 index 0000000..e1e8b3d --- /dev/null +++ b/app/logical/api_image_upload_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class APIImageUploadService + class UploadError < StandardError; end + attr_reader :params, :image, :upload + + def initialize(params) + @params = params + end + + def start! + file = params.delete(:file) + APIImage.transaction do + @image = APIImage.new(**params, creator: CurrentUser.user) + begin + @image.file = get_file(@image, file: file) + process_file(@image, @image.file) + @image.save + return @image if @image.invalid? + store_file(@image, @image.file) + @image + rescue UploadError, ActiveRecord::RecordInvalid => e + @image.exception = e + @image + end + end + end + + def get_file(image, file: nil) + return file if file.present? + raise(UploadError, "No file or source URL provided") if image.original_url.blank? + + download = FileDownload.new(image.original_url) + download.download! + end + + def process_file(image, file) + mime, ext = @image.file_header_info(file.path) + image.mime_type = mime + image.file_ext = ext + image.file_size = file.size + image.id = Digest::MD5.file(file.path).hexdigest + + width, height = calculate_dimensions(file.path) + image.width = width + image.height = height + image.validate!(:file) + end + + def store_file(image, file) + Websites.config.yiffy2_storage.put(image.path, file) + end + + def calculate_dimensions(file_path) + image = Vips::Image.new_from_file(file_path) + [image.width, image.height] + end +end diff --git a/app/logical/cloudflare_service.rb b/app/logical/cloudflare_service.rb new file mode 100644 index 0000000..46f4401 --- /dev/null +++ b/app/logical/cloudflare_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CloudflareService + def self.ips + text, code = Cache.fetch("cloudflare_ips", expires_in: 24.hours) do + resp = HTTParty.get("https://api.cloudflare.com/client/v4/ips", Websites.config.httparty_options) + [resp.body, resp.code] + end + return [] if code != 200 + + json = JSON.parse(text, symbolize_names: true) + ips = json[:result][:ipv4_cidrs] + json[:result][:ipv6_cidrs] + ips.map { |ip| IPAddr.new(ip) } + end +end diff --git a/app/logical/file_download.rb b/app/logical/file_download.rb new file mode 100644 index 0000000..d153433 --- /dev/null +++ b/app/logical/file_download.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class FileDownload + include ActiveModel::Validations + class Error < StandardError; end + + RETRIABLE_ERRORS = [Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EIO, Errno::EHOSTUNREACH, Errno::ECONNREFUSED, Timeout::Error, IOError].freeze + MAX_SIZE = 50.megabytes + + attr_reader :url + + validate :validate_url + + def initialize(url) + begin + unencoded = Addressable::URI.unencode(url) + escaped = Addressable::URI.escape(unencoded) + @url = Addressable::URI.parse(escaped) + rescue Addressable::URI::InvalidURIError + @url = nil + end + validate! + end + + def size + res = HTTParty.head(uncached_url, **httparty_options, timeout: 3) + + if res.success? + res.content_length + else + raise(HTTParty::ResponseError, res) + end + end + + def download!(tries: 3, **) + Retriable.retriable(on: RETRIABLE_ERRORS, tries: tries, base_interval: 0) do + http_get_streaming(uncached_url, **) + end + end + + def validate_url + errors.add(:base, "URL must not be blank") if url.blank? + errors.add(:base, "'#{url}' is not a valid url") if url.host.blank? + errors.add(:base, "'#{url}' is not a valid url. Did you mean 'https://#{url}'?") unless url.scheme.in?(%w[http https]) + end + + def http_get_streaming(url, file: Tempfile.new(binmode: true), max_size: MAX_SIZE) + size = 0 + + res = HTTParty.get(url, httparty_options) do |chunk| + next if [301, 302].include?(chunk.code) + + size += chunk.size + raise(Error, "File is too large (max size: #{max_size})") if size > max_size && max_size > 0 + + file.write(chunk) + end + + if res.success? + file.rewind + file + else + raise("HTTP error code: #{res.code} #{res.message}") + end + end + + # Prevent Cloudflare from potentially mangling the image + def uncached_url + return file_url unless is_cloudflare?(file_url) + + url = file_url.dup + url.query_values = url.query_values.to_h.merge(nc: Time.now) + url + end + + alias file_url url + + def httparty_options + { + timeout: 10, + stream_body: true, + connection_adapter: ValidatingConnectionAdapter, + }.deep_merge(Websites.config.httparty_options) + end + + def is_cloudflare?(url) + ip_addr = IPAddr.new(Resolv.getaddress(url.hostname)) + CloudflareService.ips.any? { |subnet| subnet.include?(ip_addr) } + end +end + +# Hook into HTTParty to validate the IP before following redirects. +# https://www.rubydoc.info/github/jnunemaker/httparty/HTTParty/ConnectionAdapter +class ValidatingConnectionAdapter < HTTParty::ConnectionAdapter + def self.call(uri, options) + ip_addr = IPAddr.new(Resolv.getaddress(uri.hostname)) + raise(FileDownload::Error, "Downloads from #{ip_addr} are not allowed") if ip_blocked?(ip_addr) + super + end + + def self.ip_blocked?(ip_addr) + ip_addr.private? || ip_addr.loopback? || ip_addr.link_local? + end +end diff --git a/app/logical/requests/e621.rb b/app/logical/requests/e621.rb index ce74ba5..6ac6d9f 100644 --- a/app/logical/requests/e621.rb +++ b/app/logical/requests/e621.rb @@ -7,7 +7,9 @@ module Requests def status r = self.class.get("/posts.json?limit=0", { + **Websites.config.httparty_options, headers: { + **Websites.config.http_headers, "User-Agent" => "E621Status/1.0.0 (https://status.e621.ws; \"donovan_dmc\")", }, timeout: 5, @@ -33,11 +35,7 @@ module Requests path = "/" path = "/posts/#{id}.json" if id path = "/posts.json?md5=#{md5}" if md5 - r = self.class.get(path, { - headers: { - "User-Agent" => "Websites/4.0.0 (https://github.com/DonovanDMC/Websites; \"donovan_dmc\")", - }, - }) + r = self.class.get(path, Websites.config.httparty_options) return nil if r.code != 200 JSON.parse(r.body)["post"] end @@ -49,20 +47,30 @@ module Requests path += "tags=md5:#{md5s.join(',')}%20limit:100" if md5s path += "%20status:#{status}" if path.include?("tags=") && !status.nil? path += "tags=status:#{status}" if !path.include?("tags=") && !status.nil? - r = self.class.get(path, { - headers: { - "User-Agent" => "Websites/4.0.0 (https://github.com/DonovanDMC/Websites; \"donovan_dmc\")", - }, - }) + Rails.logger.info("Fetching posts from e621: #{path}") if Rails.env.development? + r = self.class.get(path, Websites.config.httparty_options) JSON.parse(r.body)["posts"] || [] end + def get_all_posts(ids: [], md5s: []) + posts = [] + ids.each_slice(100) do |slice| + posts += get_posts(ids: slice, status: "any") + end + md5s -= posts.map { |p| p["file"]["md5"] } + md5s.each_slice(100) do |slice| + posts += get_posts(md5s: slice, status: "any") + end + missing = { + ids: ids - posts.pluck("id"), + md5s: md5s - posts.map { |p| p["file"]["md5"] }, + } + + { posts: posts, missing: missing } + end + def find_replacement(md5:) - r = self.class.get("/post_replacements.json?search[md5]=#{md5}", { - headers: { - "User-Agent" => "Websites/4.0.0 (https://github.com/DonovanDMC/Websites; \"donovan_dmc\")", - }, - }) + r = self.class.get("/post_replacements.json?search[md5]=#{md5}", Websites.config.httparty_options) JSON.parse(r.body)&.first end @@ -77,5 +85,9 @@ module Requests def self.get_posts(**) new.get_posts(**) end + + def self.get_all_posts(**) + new.get_all_posts(**) + end end end diff --git a/app/logical/storage_manager.rb b/app/logical/storage_manager.rb deleted file mode 100644 index 380d6f1..0000000 --- a/app/logical/storage_manager.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module StorageManager - E621Thumbnails = Local.new(base_url: Websites.config.e621_thumbnails_base_url, base_path: Websites.config.e621_thumbnails_base_path) - # E621Thumbnails = Bunny.new(base_url: Websites.config.e621_thumbnails_base_url, access_key: Websites.config.e621_thumbnails_access_key, storage_zone_name: Websites.config.e621_thumbnails_storage_zone_name) -end diff --git a/app/logical/storage_manager/bunny.rb b/app/logical/storage_manager/bunny.rb index cd22eb5..d9c78e2 100644 --- a/app/logical/storage_manager/bunny.rb +++ b/app/logical/storage_manager/bunny.rb @@ -23,7 +23,7 @@ module StorageManager end def url_for(entry) - "#{base_url}/#{entry.stripped_md5}.#{entry.filetype}" + "#{base_url}#{entry.path}" end end end diff --git a/app/logical/storage_manager/local.rb b/app/logical/storage_manager/local.rb index 0dc2346..c7b6569 100644 --- a/app/logical/storage_manager/local.rb +++ b/app/logical/storage_manager/local.rb @@ -2,6 +2,7 @@ module StorageManager class Local + DEFAULT_PERMISSIONS = 0o644 attr_reader :base_url, :base_path def initialize(base_url:, base_path:) @@ -11,28 +12,41 @@ module StorageManager def delete(path) return unless exists?(path) - File.delete("#{base_path}/#{path}") + File.delete("#{base_path}#{path}") end def exists?(path) - File.exist?("#{base_path}/#{path}") + File.exist?("#{base_path}#{path}") end def get(path) - File.read("#{base_path}/#{path}") + File.open("#{base_path}#{path}", "r", binmode: true) end - def put(path, body) - File.write("#{base_path}/#{path}", body) + def put(path, io) + temp = "#{base_path}#{path}-#{SecureRandom.uuid}.tmp" + + FileUtils.mkdir_p(File.dirname(temp)) + io.rewind + bytes_copied = IO.copy_stream(io, temp) + raise("store failed: #{bytes_copied}/#{io.size} bytes copied") if bytes_copied != io.size + + FileUtils.chmod(DEFAULT_PERMISSIONS, temp) + File.rename(temp, "#{base_path}#{path}") + rescue StandardError => e + FileUtils.rm_f(temp) + raise(e) + ensure + FileUtils.rm_f(temp) if temp end def upload(path, body) put(path, body) - "#{base_url}/#{path}" + "#{base_url}#{path}" end def url_for(entry) - "#{base_url}/#{entry.stripped_md5}.#{entry.filetype}" + "#{base_url}#{entry.path}" end end end diff --git a/app/logical/storage_manager/s3.rb b/app/logical/storage_manager/s3.rb new file mode 100644 index 0000000..241f3bf --- /dev/null +++ b/app/logical/storage_manager/s3.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module StorageManager + class S3 + attr_reader :base_url + + def initialize(endpoint:, access_key_id:, secret_access_key:, bucket:, base_url:) + @s3 = Aws::S3::Resource.new( + region: "weur", + endpoint: endpoint, + credentials: Aws::Credentials.new(access_key_id, secret_access_key), + ).bucket(bucket) + @base_url = base_url + end + + def delete(path) + return unless exists?(path) + @s3.object(trim(path)).delete + end + + def exists?(path) + @s3.object(trim(path)).exists? + end + + def get(path) + @s3.object(trim(path)).get.body + end + + def put(path, io) + @s3.object(trim(path)).put(body: io, content_type: Marcel::MimeType.for(io)) + end + + def upload(path, body) + put(path, body) + "#{base_url}#{path}" + end + + def url_for(entry) + "#{base_url}#{entry.path}" + end + + def trim(path) + # R2 for some reason doesn't trim preceding slashes + path.sub(%r{^/}, "") + end + end +end diff --git a/app/models/api_image.rb b/app/models/api_image.rb index e4d6212..d73b33a 100644 --- a/app/models/api_image.rb +++ b/app/models/api_image.rb @@ -1,10 +1,58 @@ # frozen_string_literal: true class APIImage < ApplicationRecord - CDN_URL = "https://v2.yiff.media/" - belongs_to_creator + attr_accessor :file, :exception + + validates :category, presence: true, inclusion: { in: -> { APIImage.categories.map(&:db) } } + validates :id, uniqueness: true, on: :file + validate on: :file do |image| + ext, mime = file_header_info(file.path) + image.errors.add(:file, "type is invalid (#{mime})") if ext == mime + end + validate do |image| + image.errors.add(:base, exception.message) if exception + end + + after_create :invalidate_cache + after_update :update_files, if: :saved_change_to_category? + after_destroy :delete_files + + def delete_files + Websites.config.yiffy2_storage.delete(path) + invalidate_cache + end + + def update_files + file = Websites.config.yiffy2_storage.get(path_before_last_save) + Websites.config.yiffy2_storage.put(path, file) + Websites.config.yiffy2_storage.delete(path_before_last_save) + invalidate_cache + end + + def file_header_info(file_path) + File.open(file_path) do |bin| + mime_type = Marcel::MimeType.for(bin) + ext = case mime_type + when "image/jpeg" + "jpg" + when "image/gif" + "gif" + when "image/png" + "png" + else + mime_type + end + [mime_type, ext] + end + end + + def invalidate_cache + Cache.redis.del("yiffy2:images:#{category_before_last_save}") + Cache.redis.del("yiffy2:images:#{category}") + end + module SearchMethods def random(category, limit, size_limit = nil) q = where(category: category) @@ -27,14 +75,40 @@ class APIImage < ApplicationRecord id.gsub("-", "") end + alias stripped_md5 md5 + + def path_before_last_save + "/#{category_before_last_save.tr('.', '/')}/#{md5}.#{file_ext}" + end + + def path + "/#{category.tr('.', '/')}/#{md5}.#{file_ext}" + end + def url - "#{CDN_URL}#{category.gsub('.', '/')}/#{md5}.#{file_ext}" + Websites.config.yiffy2_storage.url_for(self) end def short_url ShortUrl.override(md5, url).shorturl end + def sources_string + sources.join("\n") + end + + def sources_string=(str) + self.sources = str.split("\n").map(&:strip) + end + + def artists_string + artists.join("\n") + end + + def artists_string=(str) + self.artists = str.split("\n").map(&:strip) + end + def serializable_hash(*) { artists: artists, @@ -61,9 +135,8 @@ class APIImage < ApplicationRecord **nsfw.index_with { |c| c.split(".").map(&:capitalize).join(" > ") }, } - # noinspection RubyMismatchedArgumentType - sfw.map { |c| { name: titles[c], db: c, sfw: true } } - .concat(nsfw.map { |c| { name: titles[c], db: c, sfw: false } }) + sfw.map { |cat| APICategory.new(titles[cat], cat, true) } + .concat(nsfw.map { |cat| APICategory.new(titles[cat], cat, false) }) end def self.category_title(db) @@ -96,4 +169,32 @@ class APIImage < ApplicationRecord state: "total", },] end + + def self.sync_all + sync_e621 + end + + # TODO + def self.sync_e621 + images = joins("CROSS JOIN unnest(sources) AS source").where("source ILIKE ?", "%e621.net%").distinct.limit(300) + ids = [] + md5s = [] + images.each do |img| + img.sources.each do |source| + next unless source.include?("e621.net") + id = source.split("/").last + ids << id.to_i if id.to_i.to_s == id + end + md5s << img.md5 + end + Requests::E621.get_all_posts(ids: ids, md5s: md5s) => { posts:, missing: } + Rails.logger.info("Failed to find #{missing[:ids].length} posts") unless missing[:ids].empty? + mismatch = images.select { |img| posts.none? { |post| post["file"]["md5"] == img.md5 } } + Rails.logger.info("Found #{mismatch.length} mismatched images") unless mismatch.empty? + posts + end + + def sync + APIImageSyncJob.perform_later(self) + end end diff --git a/app/models/api_user.rb b/app/models/api_user.rb index d61a5cd..e10f4c5 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -67,8 +67,8 @@ class APIUser < ApplicationRecord end def update_avatar(hash) - return if hash == last_avatar_hash && avatar.attached? - return if Cache.fetch("avatar_update:#{id}") + return false if hash == last_avatar_hash && avatar.attached? + return false if Cache.fetch("avatar_update:#{id}") Cache.write("avatar_update:#{id}", "1", expires_in: 1.day) avatar.purge url = "https://yiff.rest/Blep.png" @@ -76,6 +76,7 @@ class APIUser < ApplicationRecord image = URI.open(url) # rubocop:disable Security/Open avatar.attach(io: image, filename: "#{hash}.webp") update!(last_avatar_hash: hash) + true end def can_create_apikey? diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 8468393..78fc6f3 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -143,6 +143,7 @@ class ApplicationRecord < ActiveRecord::Base q = all q = q.attribute_matches(:id, params[:id]) + q = q.attribute_matches(:category, params[:category]) q = q.attribute_matches(:created_at, params[:created_at]) if attribute_names.include?("created_at") q = q.attribute_matches(:updated_at, params[:updated_at]) if attribute_names.include?("updated_at") diff --git a/app/models/e621_thumbnail.rb b/app/models/e621_thumbnail.rb index 02cba06..bd49a95 100644 --- a/app/models/e621_thumbnail.rb +++ b/app/models/e621_thumbnail.rb @@ -27,11 +27,15 @@ class E621Thumbnail < ApplicationRecord end def delete_files! - StorageManager::E621Thumbnails.delete("#{stripped_md5}.#{filetype}") if StorageManager::E621Thumbnails.exists?("#{stripped_md5}.#{filetype}") + Websites.config.e621_thumbnails_storage.delete("/#{stripped_md5}.#{filetype}") if StorageManager::E621Thumbnails.exists?("#{stripped_md5}.#{filetype}") end def url - StorageManager::E621Thumbnails.url_for(self) + Websites.config.e621_thumbnails_storage.url_for(self) + end + + def path + "/#{stripped_md5}.#{filetype}" end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a2ba0d3..30934c1 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,4 +1,4 @@ -<% console if Rails.env.development? %> +<%# console if Rails.env.development? %> @@ -11,8 +11,8 @@ <%= yield :html_head %> <% end %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_include_tag "application", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "application" %> + <%= javascript_include_tag "application" %> diff --git a/app/views/static/_backtrace.html.erb b/app/views/static/_backtrace.html.erb new file mode 100644 index 0000000..0e45671 --- /dev/null +++ b/app/views/static/_backtrace.html.erb @@ -0,0 +1,11 @@ +<%# locals: (backtrace:) -%> + + diff --git a/app/views/static/error.html.erb b/app/views/static/error.html.erb index b605baa..b9dd6d6 100644 --- a/app/views/static/error.html.erb +++ b/app/views/static/error.html.erb @@ -1,12 +1,16 @@ -<% content_for(:page_title) do %> - Error +<% if CurrentUser.user.try(:is_admin?) && @exception.present? %> +

<%= @exception.class.to_s %> exception raised

+ +

<%= @message || @exception.message.dup.force_encoding("utf-8") %>

+

Log ID: <%= @log_code || "(none)" %>

+ <%= render "static/backtrace", backtrace: @exception.backtrace %> +<% elsif @message %> +

<%= @message %>

+

Log ID: <%= @log_code || "(none)" %>

+<% else %> +

An error happened but there are no details provided.

<% end %> -

<%= @message %>

-<% if Rails.env.production? %> - <% if @log_code.present? %> -

Log Code: <%= @log_code %>

- <% end %> -<% else %> - <%= @backtrace %> +<% content_for(:page_title) do %> + Unexpected Error <% end %> diff --git a/app/views/yiff_rest/api_v2/images/edit.html.erb b/app/views/yiff_rest/api_v2/images/edit.html.erb new file mode 100644 index 0000000..e5632af --- /dev/null +++ b/app/views/yiff_rest/api_v2/images/edit.html.erb @@ -0,0 +1,16 @@ +<% content_for(:page_title) do %> + YiffyAPI - Edit Image +<% end %> + +<%= render "yiff_rest/api_v2/manage/nav" %> + +
+
+ <%= simple_form_for(@image, url: yiff_rest_api_v2_manage_image_path(manage_id: params[:manage_id], id: params[:id]), method: :patch) do |f| %> + <%= f.input(:sources_string, as: :text, label: "Sources") %> + <%= f.input(:artists_string, as: :text, label: "Artists") %> + <%= f.input(:category, as: :select, collection: @categories.map { |cat| [cat.name, cat.db] }) %> + <%= f.button(:submit, "Update Image", name: nil) %> + <% end %> +
+
diff --git a/app/views/yiff_rest/api_v2/images/index.html.erb b/app/views/yiff_rest/api_v2/images/index.html.erb new file mode 100644 index 0000000..a338c35 --- /dev/null +++ b/app/views/yiff_rest/api_v2/images/index.html.erb @@ -0,0 +1,65 @@ +<%= render "yiff_rest/api_v2/manage/nav" %> + +
+ + + + + + + + + + + + <% @images.each do |img| %> + + + + + + + + <% end %> + +
ArtistsDetailsSources
+ <%= link_to(img.url, target: "_blank", rel: "noopener") do %> + <%= image_tag(img.url, class: "img-fluid img-thumbnail") %> + <% end %> + <%= img.artists.join(", ") %> +
    +
  • Resolution: <%= img.width %>x<%= img.height %>
  • +
  • MD5: <%= img.md5 %>
  • +
  • Type: <%= img.file_ext %>
  • +
  • Size: <%= number_to_human_size(img.file_size) %>
  • +
  • Creator: <%= img.creator.name %>
  • +
  • Created At: <%= compact_time(img.created_at) %>
  • +
  • Updated At: <%= compact_time(img.updated_at) %>
  • + <% unless @sp[:category].present? %> +
  • Category: <%= img.category %>
  • + <% end %> +
  • + Upload Type: + <% if img.original_url.present? %> + <%= link_to "url".html_safe, img.original_url %> + <% else %> + file + <% end %> +
  • +
+
+ <% img.sources.each do |source| %> + <%= link_to source, source %>
+ <% end %> +
+ <%= link_to "Edit", edit_yiff_rest_api_v2_manage_image_path(manage_id: params[:id], id: img.id) %> | + <%= link_to "Delete", yiff_rest_api_v2_manage_image_path(manage_id: params[:id], id: img.id), method: :delete %> +
+
+ + + diff --git a/app/views/yiff_rest/api_v2/images/new.html.erb b/app/views/yiff_rest/api_v2/images/new.html.erb new file mode 100644 index 0000000..4141775 --- /dev/null +++ b/app/views/yiff_rest/api_v2/images/new.html.erb @@ -0,0 +1,19 @@ +<% content_for(:page_title) do %> + YiffyAPI - Upload Image +<% end %> + +<%= render "yiff_rest/api_v2/manage/nav" %> + + +
+
+ <%= simple_form_for(@image, url: yiff_rest_api_v2_manage_images_path, method: :post) do |f| %> + <%= f.input(:file, as: :file) %> + <%= f.input(:original_url, label: "File URL") %> + <%= f.input(:sources_string, as: :text, label: "Sources") %> + <%= f.input(:artists_string, as: :text, label: "Artists") %> + <%= f.input(:category, as: :select, input_html: { value: params.dig(:api_image, :category) }, collection: @categories.map { |cat| [cat.name, cat.db] }) %> + <%= f.button(:submit, "Upload Image", name: nil) %> + <% end %> +
+
diff --git a/app/views/yiff_rest/api_v2/manage/_nav.html.erb b/app/views/yiff_rest/api_v2/manage/_nav.html.erb new file mode 100644 index 0000000..6485143 --- /dev/null +++ b/app/views/yiff_rest/api_v2/manage/_nav.html.erb @@ -0,0 +1,52 @@ + diff --git a/app/views/yiff_rest/api_v2/manage/index.html.erb b/app/views/yiff_rest/api_v2/manage/index.html.erb new file mode 100644 index 0000000..1e59d81 --- /dev/null +++ b/app/views/yiff_rest/api_v2/manage/index.html.erb @@ -0,0 +1,40 @@ +<%= render "yiff_rest/api_v2/manage/nav" %> + + + + + + + + + + + <% @categories.each do |category| %> + <% total = category.real_count %> + + + + + + <% end %> + +
Category NameDetails
<%= category.name %> +
    +
  • ID: <%= category.db %>
  • +
  • Count: <%= total %> (cache: <%= category.count %>)
  • +
  • Created At: <%= compact_time(category.created_at) %>
  • +
  • Updated At: <%= compact_time(category.updated_at) %>
  • +
  • SFW: <%= category.sfw ? "Yes" : "No" %>
  • +
  • + Sources: +
      + <% category.sources.each do |source| %> +
    • <%= source[:name] %>: <%= source[:count] %> + <%# no point in having a percentage since they won't add up to 100 %> + <%# ((source[:count].to_f / total.to_f) * 100).round(0) %> +
    • + <% end %> +
    +
  • +
+
<%= link_to "Manage", yiff_rest_api_v2_manage_images_path(search: { category: category.db }) %>
diff --git a/config/default_config.rb b/config/default_config.rb index 9037ccf..6498c18 100644 --- a/config/default_config.rb +++ b/config/default_config.rb @@ -107,14 +107,6 @@ module Websites def e621_thumbnails_storage_zone_name end - def e621_thumbnails_base_url - "https://thumbs.yiff.media" - end - - def e621_thumbnails_base_path - "/data/e621-thumbnails" - end - def e621_thumbnails_webhook # Requests::DiscordWebhook.new(id: "", token: "") end @@ -211,6 +203,53 @@ module Websites ].join("; "), } end + + def http_headers + { + "User-Agent" => "Websites/4.0.0 (https://github.com/DonovanDMC/Websites; \"donovan_dmc\")", + } + end + + def httparty_options + { + timeout: 10, + open_timout: 5, + headers: http_headers, + } + end + + def yiffy2_cdn_url + # return "http://yiffy2.local" if Rails.env.development? + "https://v2.yiff.media" + end + + def yiffy2_bucket_name + end + + def yiffy2_access_key_id + end + + def yiffy2_secret_access_key + end + + def yiffy2_s3_endpoint + end + + def e621_thumbnails_storage + # Bunny.new(base_url: Websites.config.e621_thumbnails_base_url, access_key: Websites.config.e621_thumbnails_access_key, storage_zone_name: Websites.config.e621_thumbnails_storage_zone_name) + StorageManager::Local.new(base_url: "https://thumbs.yiff.media", base_path: "/data/e621-thumbnails") + end + + def yiffy2_storage + return StorageManager::Local.new(base_url: yiffy2_cdn_url, base_path: "/data/yiffy2") if Rails.env.development? + StorageManager::S3.new( + endpoint: yiffy2_s3_endpoint, + access_key_id: yiffy2_access_key_id, + secret_access_key: yiffy2_secret_access_key, + bucket: yiffy2_bucket_name, + base_url: yiffy2_cdn_url, + ) + end end class EnvironmentConfiguration diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 0000000..0f46236 --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require "pagy/extras/bootstrap" +Pagy::DEFAULT[:items] = 20 diff --git a/config/routes/domain_constraint.rb b/config/routes/domain_constraint.rb index 6a8bf6a..1aa88bb 100644 --- a/config/routes/domain_constraint.rb +++ b/config/routes/domain_constraint.rb @@ -11,17 +11,25 @@ class DomainConstraint end def matches?(request) - Rails.logger.info("Domain: #{@domain}; Subdomain: #{@subdomain}; Current Host: #{request.domain}; Current Subdomains: #{request.subdomain}; Matches (Domain): #{domain_matches?(request)}; Matches (Subdomain): #{subdomain_matches?(request)}") if Rails.env.development? + if Rails.env.development? + domain = request.env["websites.dev_domain"].presence || request.domain + subdomains = request.env["websites.dev_subdomains"].presence || request.subdomains + Rails.logger.info("Domain: #{@domain}; Subdomain: #{@subdomain}; Current Host: #{domain}; Current Subdomains: #{subdomains}; Matches (Domain): #{domain_matches?(request)}; Matches (Subdomain): #{subdomain_matches?(request)}") + end subdomain_matches?(request) && domain_matches?(request) end private def domain_matches?(request) - request.domain == @domain + (Rails.env.development? && request.env["websites.dev_domain"] == @domain) || request.domain == @domain end def subdomain_matches?(request) + if Rails.env.development? && !request.env["websites.dev_subdomains"].nil? + return true if @subdomain.nil? && request.env["websites.dev_subdomains"].blank? + return true if request.env["websites.dev_subdomains"] == @subdomain + end (@subdomain.nil? && request.subdomain.blank?) || request.subdomain == @subdomain end end diff --git a/config/routes/yiff_rest_routes.rb b/config/routes/yiff_rest_routes.rb index 4e8a0f5..71545c3 100644 --- a/config/routes/yiff_rest_routes.rb +++ b/config/routes/yiff_rest_routes.rb @@ -47,6 +47,13 @@ module YiffRestRoutes constraints(DomainConstraint.new(DOMAIN, V2)) do namespace(:api_v2, path: "") do + resources(:manage, constraints: { id: /[a-z0-9\-.]+/i }, only: %i[index]) do + collection do + get(:logout) + put(:sync) + resources(:images, as: "manage_images") + end + end get("/", to: redirect("https://yiff.rest")) get(:robots, constraints: { format: "txt" }) get(:state, to: redirect("https://state.yiff.rest")) @@ -56,7 +63,7 @@ module YiffRestRoutes get("/categories/(*category)", action: :category, as: :api_v2_category, constraints: { category: /[a-z0-9\-.]+/i }) get("/images/:id", action: :image, as: :api_v2_image, constraints: { id: /[a-f0-9]{32}/ }) post(:bulk, constraints: { format: "json" }, defaults: { format: :json }) - get("/(*category)", action: :index, as: :api_v2_images) + get("/(*category)", action: :index, as: :api_v2_images, constraints: { category: /(?!rails).*/ }) end end @@ -65,6 +72,7 @@ module YiffRestRoutes get(:count_servers) get(:flags) get(:apikey) + get(:manage_images) post(:interactions) end diff --git a/db/migrate/20240504225048_drop_api_images_created_by.rb b/db/migrate/20240504225048_drop_api_images_created_by.rb new file mode 100644 index 0000000..0b2e293 --- /dev/null +++ b/db/migrate/20240504225048_drop_api_images_created_by.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DropAPIImagesCreatedBy < ActiveRecord::Migration[7.1] + def change + remove_column(:api_images, :created_by, :string, null: false) + end +end diff --git a/db/schema.rb b/db/schema.rb index 29c4269..80c3d78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_01_02_073042) do +ActiveRecord::Schema[7.1].define(version: 2024_05_04_225048) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -51,7 +51,6 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_02_073042) do t.integer "height", null: false t.string "mime_type", null: false t.string "category", null: false - t.string "created_by", null: false t.string "original_url" t.string "file_ext", null: false t.integer "file_size", null: false diff --git a/docker-compose.yml b/docker-compose.yml index a08e17f..b5ac1b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - .:/app - e621_thumbnail_data:/data/e621-thumbnails - oceanic_docs_data:/data/oceanic-docs + - /var/www/yiffy2:/data/yiffy2 tmpfs: - /app/tmp/pids environment: diff --git a/lib/middleware/dev_host.rb b/lib/middleware/dev_host.rb index 34c8e62..8bf6876 100644 --- a/lib/middleware/dev_host.rb +++ b/lib/middleware/dev_host.rb @@ -1,14 +1,21 @@ +# frozen_string_literal: true + module Middleware class DevHost + DEFAULT_HOST = "v2.yiff.rest" def initialize(app) @app = app end def call(env) request = Rack::Request.new(env) - domain = request.params["domain"] + domain = request.params["domain"].presence || DEFAULT_HOST - env["HTTP_HOST"] = domain if Rails.env.development? && domain + if Rails.env.development? && domain + env["websites.dev_host"] = domain + env["websites.dev_domain"] = domain.split(".").last(2).join(".") + env["websites.dev_subdomains"] = domain.split(".")[0..-3].join(".") + end @app.call(env) end diff --git a/package.json b/package.json index 8592b08..58ddd2e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "packageManager": "yarn@1.22.19", "dependencies": { "@hotwired/stimulus": "^3.2.2", - "@hotwired/turbo-rails": "^7.3.0", "@popperjs/core": "^2.11.8", "@rails/ujs": "^7.1.2", "bootstrap": "^5.3.2", diff --git a/yarn.lock b/yarn.lock index 3a7cdb6..84167da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -192,19 +192,6 @@ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== -"@hotwired/turbo-rails@^7.3.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.3.0.tgz#422c21752509f3edcd6c7b2725bbe9e157815f51" - integrity sha512-fvhO64vp/a2UVQ3jue9WTc2JisMv9XilIC7ViZmXAREVwiQ2S4UC7Go8f9A1j4Xu7DBI6SbFdqILk5ImqVoqyA== - dependencies: - "@hotwired/turbo" "^7.3.0" - "@rails/actioncable" "^7.0" - -"@hotwired/turbo@^7.3.0": - version "7.3.0" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.3.0.tgz#2226000fff1aabda9fd9587474565c9929dbf15d" - integrity sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g== - "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -268,11 +255,6 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@rails/actioncable@^7.0": - version "7.1.1" - resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.1.1.tgz#e8c49769d41f35a4473133c259cc98adc04dddf8" - integrity sha512-ZRJ9rdwFQQjRbtgJnweY0/4UQyxN6ojEGRdib0JkjnuIciv+4ok/aAeZmBJqNreTMaBqS0eHyA9hCArwN58opg== - "@rails/ujs@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.2.tgz#ea903bcc0224e17156015d995b6f1b83e27d64b2"