From 2ae912a8be672a117c809f7405f2ab94d8ca9759 Mon Sep 17 00:00:00 2001 From: BrokenEagle Date: Sat, 14 Nov 2020 17:59:14 +0000 Subject: [PATCH 001/132] Add childlike font --- app/javascript/src/styles/common/fonts.css | 9 +++++++++ public/fonts/gisele.ttf | Bin 0 -> 13356 bytes 2 files changed, 9 insertions(+) create mode 100644 public/fonts/gisele.ttf diff --git a/app/javascript/src/styles/common/fonts.css b/app/javascript/src/styles/common/fonts.css index a61f3036b..42568c202 100644 --- a/app/javascript/src/styles/common/fonts.css +++ b/app/javascript/src/styles/common/fonts.css @@ -320,3 +320,12 @@ local("Anarchy"), url("../../../../../public/fonts/Anarchy.ttf") format("truetype"); } + +/* https://www.1001freefonts.com/gisele-script.font */ +@font-face { + font-family: "Childlike"; + font-display: swap; + src: + local("Gisele Script"), + url("../../../../../public/fonts/gisele.ttf") format("truetype"); +} diff --git a/public/fonts/gisele.ttf b/public/fonts/gisele.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d04d1c3309e89df1df9cd172f22e0be2b5763c8e GIT binary patch literal 13356 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 Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 002/132] posts: remove "repopulated 1 old tag" message. Remove the "Repopulated 1 old tag" message. Show "Created 1 new tag" instead. The distinction between creating a brand new tag and repopulating an empty tag doesn't matter. --- app/models/post.rb | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/models/post.rb b/app/models/post.rb index 8a33ac746..5117102f2 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1377,9 +1377,7 @@ class Post < ApplicationRecord def added_tags_are_valid new_tags = added_tags.select(&:empty?) - new_general_tags = new_tags.select(&:general?) - new_artist_tags = new_tags.select(&:artist?) - repopulated_tags = new_tags.select { |t| !t.general? && !t.meta? && (t.created_at < 1.hour.ago) } + new_artist_tags, new_general_tags = new_tags.partition(&:artist?) if new_general_tags.present? n = new_general_tags.size @@ -1387,12 +1385,6 @@ class Post < ApplicationRecord self.warnings[:base] << "Created #{n} new #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}" end - if repopulated_tags.present? - n = repopulated_tags.size - tag_wiki_links = repopulated_tags.map { |tag| "[[#{tag.name}]]" } - self.warnings[:base] << "Repopulated #{n} old #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}" - end - new_artist_tags.each do |tag| if tag.artist.blank? self.warnings[:base] << "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[/artists/new?artist%5Bname%5D=#{CGI.escape(tag.name)}]" From adc1c2c2cc1b9e76e08257cff30e7a45076f6066 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 003/132] autocomplete: refactor javascript to use /autocomplete endpoint. This refactors the autocomplete Javascript to use a single dedicated /autocomplete.json endpoint instead of a bunch of separate endpoints. This simplifies the autocomplete Javascript by making it so that instead of calling a different endpoint for each type of query (for users, wiki pages, pools, artists, etc), then having to parse the results of each call to get the data we need, we can call a single endpoint that returns exactly what we need. This also means we don't have to parse searches clientside in order to autocomplete metatags. Instead we can just pass the search term to the server and let it parse the search, which is easy to do serverside. Finally, this makes autocomplete easier to test, and it makes it easier to add more sophisticated autocomplete behavior, since most of the logic lives serverside. --- app/controllers/autocomplete_controller.rb | 15 +- .../src/javascripts/autocomplete.js.erb | 260 ++---------------- app/logical/autocomplete_service.rb | 165 +++++++++++ app/models/artist.rb | 4 + app/models/wiki_page.rb | 4 + app/views/static/opensearch.xml.erb | 2 +- .../autocomplete_controller_test.rb | 26 +- test/unit/autocomplete_service_test.rb | 120 ++++++++ 8 files changed, 345 insertions(+), 251 deletions(-) create mode 100644 app/logical/autocomplete_service.rb create mode 100644 test/unit/autocomplete_service_test.rb diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 37fa3565b..68c46d793 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -2,15 +2,12 @@ class AutocompleteController < ApplicationController respond_to :xml, :json def index - @tags = Tag.names_matches_with_aliases(params[:query], params.fetch(:limit, 10).to_i) + @query = params.dig(:search, :query) + @type = params.dig(:search, :type) + @limit = params.fetch(:limit, 10).to_i + @autocomplete = AutocompleteService.new(@query, @type, current_user: CurrentUser.user, limit: @limit) - if request.variant.opensearch? - expires_in 1.hour - results = [params[:query], @tags.map(&:pretty_name)] - respond_with(results) - else - # XXX - respond_with(@tags.map(&:attributes)) - end + @results = @autocomplete.autocomplete_results + respond_with(@results) end end diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index a1d32f478..1c81f3651 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -3,16 +3,10 @@ import CurrentUser from './current_user' let Autocomplete = {}; /* eslint-disable */ -Autocomplete.METATAGS = <%= PostQueryBuilder::METATAGS.to_json.html_safe %>; Autocomplete.TAG_CATEGORIES = <%= TagCategory.mapping.to_json.html_safe %>; -Autocomplete.ORDER_METATAGS = <%= PostQueryBuilder::ORDER_METATAGS.to_json.html_safe %>; -Autocomplete.DISAPPROVAL_REASONS = <%= PostDisapproval::REASONS.to_json.html_safe %>; /* eslint-enable */ -Autocomplete.MISC_STATUSES = ["deleted", "active", "pending", "flagged", "banned", "modqueue", "unmoderated", "appealed"]; Autocomplete.TAG_PREFIXES = "-|~|" + Object.keys(Autocomplete.TAG_CATEGORIES).map(category => category + ":").join("|"); -Autocomplete.METATAGS_REGEX = Autocomplete.METATAGS.concat(Object.keys(Autocomplete.TAG_CATEGORIES)).join("|"); -Autocomplete.TERM_REGEX = new RegExp(`([-~]*)(?:(${Autocomplete.METATAGS_REGEX}):)?(\\S*)$`, "i"); Autocomplete.MAX_RESULTS = 10; Autocomplete.initialize_all = function() { @@ -39,20 +33,20 @@ Autocomplete.initialize_all = function() { this.initialize_tag_autocomplete(); this.initialize_mention_autocomplete($("form div.input.dtext textarea")); - this.initialize_fields($('[data-autocomplete="tag"]'), Autocomplete.tag_source); - this.initialize_fields($('[data-autocomplete="artist"]'), Autocomplete.artist_source); - this.initialize_fields($('[data-autocomplete="pool"]'), Autocomplete.pool_source); - this.initialize_fields($('[data-autocomplete="user"]'), Autocomplete.user_source); - this.initialize_fields($('[data-autocomplete="wiki-page"]'), Autocomplete.wiki_source); - this.initialize_fields($('[data-autocomplete="favorite-group"]'), Autocomplete.favorite_group_source); - this.initialize_fields($('[data-autocomplete="saved-search-label"]'), Autocomplete.saved_search_source); + this.initialize_fields($('[data-autocomplete="tag"]'), "tag"); + this.initialize_fields($('[data-autocomplete="artist"]'), "artist"); + this.initialize_fields($('[data-autocomplete="pool"]'), "pool"); + this.initialize_fields($('[data-autocomplete="user"]'), "user"); + this.initialize_fields($('[data-autocomplete="wiki-page"]'), "wiki_page"); + this.initialize_fields($('[data-autocomplete="favorite-group"]'), "favorite_group"); + this.initialize_fields($('[data-autocomplete="saved-search-label"]'), "saved_search_label"); } } -Autocomplete.initialize_fields = function($fields, autocomplete) { +Autocomplete.initialize_fields = function($fields, type) { $fields.autocomplete({ source: async function(request, respond) { - let results = await autocomplete(request.term); + let results = await Autocomplete.autocomplete_source(request.term, type); respond(results); }, }); @@ -84,7 +78,7 @@ Autocomplete.initialize_mention_autocomplete = function($fields) { } if (name) { - let results = await Autocomplete.user_source(name, "@"); + let results = await Autocomplete.autocomplete_source(name, "mention"); resp(results); } } @@ -106,76 +100,18 @@ Autocomplete.initialize_tag_autocomplete = function() { return false; }, source: async function(req, resp) { - var query = Autocomplete.parse_query(req.term, this.element.get(0).selectionStart); - var metatag = query.metatag; - var term = query.term; - var results = []; - - switch (metatag) { - case "order": - case "status": - case "rating": - case "locked": - case "child": - case "parent": - case "filetype": - case "disapproved": - case "embedded": - results = Autocomplete.static_metatag_source(term, metatag); - break; - case "user": - case "approver": - case "commenter": - case "comm": - case "noter": - case "noteupdater": - case "commentaryupdater": - case "artcomm": - case "fav": - case "ordfav": - case "appealer": - case "flagger": - case "upvote": - case "downvote": - results = await Autocomplete.user_source(term, metatag + ":"); - break; - case "pool": - case "ordpool": - results = await Autocomplete.pool_source(term, metatag + ":"); - break; - case "favgroup": - case "ordfavgroup": - results = await Autocomplete.favorite_group_source(term, metatag + ":", CurrentUser.data("id")); - break; - case "search": - results = await Autocomplete.saved_search_source(term, metatag + ":"); - break; - case "tag": - results = await Autocomplete.tag_source(term); - break; - default: - results = []; - break; - } - + let term = Autocomplete.current_term(this.element); + let results = await Autocomplete.autocomplete_source(term, "tag_query"); resp(results); } }); } -Autocomplete.parse_query = function(text, caret) { - let before_caret_text = text.substring(0, caret); - let match = before_caret_text.match(Autocomplete.TERM_REGEX); - - let operator = match[1]; - let metatag = match[2] ? match[2].toLowerCase() : "tag"; - let term = match[3]; - - if (metatag in Autocomplete.TAG_CATEGORIES) { - metatag = "tag"; - } - - return { operator: operator, metatag: metatag, term: term }; +Autocomplete.current_term = function($input) { + let query = $input.get(0).value; + let caret = $input.get(0).selectionStart; + let match = query.substring(0, caret).match(/\S*/); + return match[0]; }; // Update the input field with the item currently focused in the @@ -264,166 +200,12 @@ Autocomplete.render_item = function(list, item) { return $list_item.appendTo(list); }; -Autocomplete.static_metatags = { - order: Autocomplete.ORDER_METATAGS, - status: ["any"].concat(Autocomplete.MISC_STATUSES), - rating: [ - "safe", "questionable", "explicit" - ], - locked: [ - "rating", "note", "status" - ], - embedded: [ - "true", "false" - ], - child: ["any", "none"].concat(Autocomplete.MISC_STATUSES), - parent: ["any", "none"].concat(Autocomplete.MISC_STATUSES), - filetype: [ - "jpg", "png", "gif", "swf", "zip", "webm", "mp4" - ], - commentary: [ - "true", "false", "translated", "untranslated" - ], - disapproved: Autocomplete.DISAPPROVAL_REASONS -} - -Autocomplete.static_metatag_source = function(term, metatag) { - var sub_metatags = this.static_metatags[metatag]; - - var matches = sub_metatags.filter(sub_metatag => sub_metatag.startsWith(term.toLowerCase())); - matches = matches.map(sub_metatag => `${metatag}:${sub_metatag}`).sort().slice(0, Autocomplete.MAX_RESULTS); - - return matches; -} - -Autocomplete.tag_source = async function(term) { - if (term === "") { - return []; - } - - let tags = await $.getJSON("/tags/autocomplete", { - "search[name_matches]": term, - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return tags.map(function(tag) { - return { - type: "tag", - label: tag.name.replace(/_/g, " "), - antecedent: tag.antecedent_name, - value: tag.name, - category: tag.category, - source: tag.source, - weight: tag.weight, - post_count: tag.post_count - }; - }); -} - -Autocomplete.artist_source = async function(term) { - let artists = await $.getJSON("/artists", { - "search[name_like]": term.trim().replace(/\s+/g, "_") + "*", - "search[is_deleted]": false, - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return artists.map(function(artist) { - return { - type: "tag", - label: artist.name.replace(/_/g, " "), - value: artist.name, - category: Autocomplete.TAG_CATEGORIES.artist, - }; - }); -}; - -Autocomplete.wiki_source = async function(term) { - let wiki_pages = await $.getJSON("/wiki_pages", { - "search[title_normalize]": term + "*", - "search[hide_deleted]": "Yes", - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return wiki_pages.map(function(wiki_page) { - return { - type: "tag", - label: wiki_page.title.replace(/_/g, " "), - value: wiki_page.title, - category: wiki_page.category_name - }; - }); -}; - -Autocomplete.user_source = async function(term, prefix = "") { - let users = await $.getJSON("/users", { - "search[order]": "post_upload_count", - "search[current_user_first]": "true", - "search[name_matches]": term + "*", +Autocomplete.autocomplete_source = function(query, type) { + return $.getJSON("/autocomplete", { + "search[query]": query, + "search[type]": type, "limit": Autocomplete.MAX_RESULTS }); - - return users.map(function(user) { - return { - type: "user", - label: user.name.replace(/_/g, " "), - value: prefix + user.name, - level: user.level_string - }; - }); -}; - -Autocomplete.pool_source = async function(term, prefix = "") { - let pools = await $.getJSON("/pools", { - "search[name_matches]": term, - "search[is_deleted]": false, - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS - }); - - return pools.map(function(pool) { - return { - type: "pool", - label: pool.name.replace(/_/g, " "), - value: prefix + pool.name, - post_count: pool.post_count, - category: pool.category - }; - }); -}; - -Autocomplete.favorite_group_source = async function(term, prefix = "", creator_id = null) { - let favgroups = await $.getJSON("/favorite_groups", { - "search[creator_id]": creator_id, - "search[name_matches]": term, - "limit": Autocomplete.MAX_RESULTS - }); - - return favgroups.map(function(favgroup) { - return { - label: favgroup.name.replace(/_/g, " "), - value: prefix + favgroup.name, - post_count: favgroup.post_count - }; - }); -}; - -Autocomplete.saved_search_source = async function(term, prefix = "") { - let labels = await $.getJSON("/saved_searches/labels", { - "search[label]": term + "*", - "limit": Autocomplete.MAX_RESULTS - }); - - return labels.map(function(label) { - return { - label: label.replace(/_/g, " "), - value: prefix + label, - }; - }); } $(document).ready(function() { diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb new file mode 100644 index 000000000..4896bf225 --- /dev/null +++ b/app/logical/autocomplete_service.rb @@ -0,0 +1,165 @@ +class AutocompleteService + POST_STATUSES = %w[active deleted pending flagged appealed banned modqueue unmoderated] + + STATIC_METATAGS = { + status: %w[any] + POST_STATUSES, + child: %w[any none] + POST_STATUSES, + parent: %w[any none] + POST_STATUSES, + rating: %w[safe questionable explicit], + locked: %w[rating note status], + embedded: %w[true false], + filetype: %w[jpg png gif swf zip webm mp4], + commentary: %w[true false translated untranslated], + disapproved: PostDisapproval::REASONS, + order: PostQueryBuilder::ORDER_METATAGS + } + + attr_reader :query, :type, :limit, :current_user + + def initialize(query, type, current_user: User.anonymous, limit: 10) + @query = query.to_s + @type = type.to_sym + @current_user = current_user + @limit = limit + end + + def autocomplete_results + case type + when :tag_query + autocomplete_tag_query(query) + when :tag + autocomplete_tag(query) + when :artist + autocomplete_artist(query) + when :wiki_page + autocomplete_wiki_page(query) + when :user + autocomplete_user(query) + when :mention + autocomplete_mention(query) + when :pool + autocomplete_pool(query) + when :favorite_group + autocomplete_favorite_group(query) + when :saved_search_label + autocomplete_saved_search_label(query) + when :opensearch + autocomplete_opensearch(query) + else + [] + end + end + + def autocomplete_tag_query(string) + term = PostQueryBuilder.new(string).terms.first + return [] if term.nil? + + case term.type + when :tag + autocomplete_tag(term.name) + when :metatag + autocomplete_metatag(term.name, term.value) + end + end + + def autocomplete_tag(string) + tags = Tag.names_matches_with_aliases(string, limit) + + tags.map do |tag| + { type: "tag", label: tag.name.tr("_", " "), value: tag.name, antecedent: tag.antecedent_name, category: tag.category, post_count: tag.post_count, source: nil, weight: nil } + end + end + + def autocomplete_metatag(metatag, value) + results = case metatag.to_sym + when :user, :approver, :commenter, :comm, :noter, :noteupdater, :commentaryupdater, + :artcomm, :fav, :ordfav, :appealer, :flagger, :upvote, :downvote + autocomplete_user(value) + when :pool, :ordpool + autocomplete_pool(value) + when :favgroup, :ordfavgroup + autocomplete_favorite_group(value) + when :search + autocomplete_saved_search_label(value) + when *STATIC_METATAGS.keys + autocomplete_static_metatag(metatag, value) + end + + results.map do |result| + { **result, value: metatag + ":" + result[:value] } + end + end + + def autocomplete_static_metatag(metatag, value) + values = STATIC_METATAGS[metatag.to_sym] + results = values.select { |v| v.starts_with?(value) }.sort.take(limit) + + results.map do |v| + { label: metatag + ":" + v, value: v } + end + end + + def autocomplete_pool(string) + string = "*" + string + "*" unless string.include?("*") + pools = Pool.undeleted.name_matches(string).search(order: "post_count").limit(limit) + + pools.map do |pool| + { type: "pool", label: pool.pretty_name, value: pool.name, post_count: pool.post_count, category: pool.category } + end + end + + def autocomplete_favorite_group(string) + string = "*" + string + "*" unless string.include?("*") + favgroups = FavoriteGroup.visible(current_user).where(creator: current_user).name_matches(string).search(order: "post_count").limit(limit) + + favgroups.map do |favgroup| + { label: favgroup.pretty_name, value: favgroup.name, post_count: favgroup.post_count } + end + end + + def autocomplete_saved_search_label(string) + labels = SavedSearch.search_labels(current_user.id, label: string).take(limit) + + labels.map do |label| + { label: label.tr("_", " "), value: label } + end + end + + def autocomplete_artist(string) + string = string + "*" unless string.include?("*") + artists = Artist.undeleted.name_matches(string).search(order: "post_count").limit(limit) + + artists.map do |artist| + { type: "tag", label: artist.pretty_name, value: artist.name, category: Tag.categories.artist } + end + end + + def autocomplete_wiki_page(string) + string = string + "*" unless string.include?("*") + wiki_pages = WikiPage.undeleted.title_matches(string).search(order: "post_count").limit(limit) + + wiki_pages.map do |wiki_page| + { type: "tag", label: wiki_page.pretty_title, value: wiki_page.title, category: wiki_page.tag&.category } + end + end + + def autocomplete_user(string) + string = string + "*" unless string.include?("*") + users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit) + + users.map do |user| + { type: "user", label: user.pretty_name, value: user.name, level: user.level_string } + end + end + + def autocomplete_mention(string) + autocomplete_user(string).map do |result| + { **result, value: "@" + result[:value] } + end + end + + def autocomplete_opensearch(string) + results = autocomplete_tag(string).map { |result| result[:value] } + [query, results] + end +end diff --git a/app/models/artist.rb b/app/models/artist.rb index cc604789c..abc7260bf 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -203,6 +203,10 @@ class Artist < ApplicationRecord end module SearchMethods + def name_matches(query) + where_like(:name, normalize_name(query)) + end + def any_other_name_matches(regex) where(id: Artist.from("unnest(other_names) AS other_name").where_regex("other_name", regex)) end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index e10c1312f..6f914bd25 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -33,6 +33,10 @@ class WikiPage < ApplicationRecord where(title: normalize_title(title)) end + def title_matches(title) + where_like(:title, normalize_title(title)) + end + def other_names_include(name) name = normalize_other_name(name) subquery = WikiPage.from("unnest(other_names) AS other_name").where_iequals("other_name", name) diff --git a/app/views/static/opensearch.xml.erb b/app/views/static/opensearch.xml.erb index 8652a2f9c..ce8737643 100644 --- a/app/views/static/opensearch.xml.erb +++ b/app/views/static/opensearch.xml.erb @@ -4,5 +4,5 @@ <%= Danbooru.config.app_name %> search <%= root_url %>favicon.ico - + diff --git a/test/functional/autocomplete_controller_test.rb b/test/functional/autocomplete_controller_test.rb index 51c064029..4dd62ee1a 100644 --- a/test/functional/autocomplete_controller_test.rb +++ b/test/functional/autocomplete_controller_test.rb @@ -1,6 +1,17 @@ require "test_helper" class AutocompleteControllerTest < ActionDispatch::IntegrationTest + def autocomplete(query, type) + get autocomplete_index_path(search: { query: query, type: type }), as: :json + assert_response :success + + response.parsed_body.map { |result| result["value"] } + end + + def assert_autocomplete_equals(expected_value, query, type) + assert_equal(expected_value, autocomplete(query, type)) + end + context "Autocomplete controller" do context "index action" do setup do @@ -8,9 +19,20 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest end should "work for opensearch queries" do - get autocomplete_index_path(query: "azur", variant: "opensearch"), as: :json + get autocomplete_index_path(search: { query: "azur", type: "opensearch" }), as: :json + assert_response :success - assert_equal(["azur", ["azur lane"]], response.parsed_body) + assert_equal(["azur", ["azur_lane"]], response.parsed_body) + end + + should "work for tag queries" do + assert_autocomplete_equals(["azur_lane"], "azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "-azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "~azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "AZUR", "tag_query") + + assert_autocomplete_equals(["rating:safe"], "rating:s", "tag_query") + assert_autocomplete_equals(["rating:safe"], "-rating:s", "tag_query") end end end diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb new file mode 100644 index 000000000..fdcf7aeb7 --- /dev/null +++ b/test/unit/autocomplete_service_test.rb @@ -0,0 +1,120 @@ +require 'test_helper' + +class AutocompleteServiceTest < ActiveSupport::TestCase + def autocomplete(query, type, **options) + results = AutocompleteService.new(query, type, **options).autocomplete_results + results.map { |r| r[:value] } + end + + def assert_autocomplete_includes(expected_value, query, type, **options) + assert_includes(autocomplete(query, type, **options), expected_value) + end + + def assert_autocomplete_equals(expected_value, query, type, **options) + assert_equal(expected_value, autocomplete(query, type, **options)) + end + + context "#autocomplete method" do + should "autocomplete artists" do + create(:artist, name: "bkub") + assert_autocomplete_includes("bkub", "bk", :artist) + end + + should "autocomplete wiki pages" do + create(:wiki_page, title: "help:home") + assert_autocomplete_includes("help:home", "help", :wiki_page) + end + + should "autocomplete users" do + @user = create(:user, name: "fumimi") + + as(@user) do + assert_autocomplete_includes("fumimi", "fu", :user) + assert_autocomplete_includes("@fumimi", "fu", :mention) + assert_autocomplete_includes("user:fumimi", "user:fu", :tag_query) + end + end + + should "autocomplete pools" do + as(create(:user)) do + create(:pool, name: "Disgustingly_Adorable") + end + + assert_autocomplete_includes("Disgustingly_Adorable", "disgust", :pool) + assert_autocomplete_includes("pool:Disgustingly_Adorable", "pool:disgust", :tag_query) + assert_autocomplete_includes("pool:Disgustingly_Adorable", "-pool:disgust", :tag_query) + end + + should "autocomplete favorite groups" do + user = create(:user) + create(:favorite_group, name: "Stuff", creator: user) + + assert_autocomplete_equals(["Stuff"], "stu", :favorite_group, current_user: user) + assert_autocomplete_equals([], "stu", :favorite_group, current_user: User.anonymous) + + assert_autocomplete_equals(["favgroup:Stuff"], "favgroup:stu", :tag_query, current_user: user) + assert_autocomplete_equals([], "favgroup:stu", :tag_query, current_user: User.anonymous) + end + + should "autocomplete saved search labels" do + user = create(:user) + create(:saved_search, query: "bkub", labels: ["artists"], user: user) + + assert_autocomplete_equals(["artists"], "art", :saved_search_label, current_user: user) + + assert_autocomplete_equals(["search:artists"], "search:art", :tag_query, current_user: user) + end + + should "autocomplete single tags" do + create(:tag, name: "touhou") + assert_autocomplete_includes("touhou", "tou", :tag) + end + + context "for a tag search" do + should "autocomplete tags" do + create(:tag, name: "touhou") + + assert_autocomplete_includes("touhou", "tou", :tag_query) + assert_autocomplete_includes("touhou", "TOU", :tag_query) + assert_autocomplete_includes("touhou", "-tou", :tag_query) + assert_autocomplete_includes("touhou", "~tou", :tag_query) + end + + should "autocomplete static metatags" do + assert_autocomplete_equals(["status:active"], "status:act", :tag_query) + assert_autocomplete_equals(["parent:active"], "parent:act", :tag_query) + assert_autocomplete_equals(["child:active"], "child:act", :tag_query) + + assert_autocomplete_equals(["rating:safe"], "rating:s", :tag_query) + assert_autocomplete_equals(["rating:questionable"], "rating:q", :tag_query) + assert_autocomplete_equals(["rating:explicit"], "rating:e", :tag_query) + + assert_autocomplete_equals(["locked:rating"], "locked:r", :tag_query) + assert_autocomplete_equals(["locked:status"], "locked:s", :tag_query) + assert_autocomplete_equals(["locked:note"], "locked:n", :tag_query) + + assert_autocomplete_equals(["embedded:true"], "embedded:t", :tag_query) + assert_autocomplete_equals(["embedded:false"], "embedded:f", :tag_query) + + assert_autocomplete_equals(["filetype:jpg"], "filetype:j", :tag_query) + assert_autocomplete_equals(["filetype:png"], "filetype:p", :tag_query) + assert_autocomplete_equals(["filetype:gif"], "filetype:g", :tag_query) + assert_autocomplete_equals(["filetype:swf"], "filetype:s", :tag_query) + assert_autocomplete_equals(["filetype:zip"], "filetype:z", :tag_query) + assert_autocomplete_equals(["filetype:webm"], "filetype:w", :tag_query) + assert_autocomplete_equals(["filetype:mp4"], "filetype:m", :tag_query) + + assert_autocomplete_equals(["commentary:true"], "commentary:tru", :tag_query) + assert_autocomplete_equals(["commentary:false"], "commentary:fal", :tag_query) + assert_autocomplete_equals(["commentary:translated"], "commentary:trans", :tag_query) + assert_autocomplete_equals(["commentary:untranslated"], "commentary:untrans", :tag_query) + + assert_autocomplete_equals(["disapproved:breaks_rules"], "disapproved:break", :tag_query) + assert_autocomplete_equals(["disapproved:poor_quality"], "disapproved:poor", :tag_query) + assert_autocomplete_equals(["disapproved:disinterest"], "disapproved:dis", :tag_query) + + assert_autocomplete_equals(["order:score", "order:score_asc"], "order:sco", :tag_query) + end + end + end +end From b0be8ae4562644469cd481ae3e8a075e040c4bc4 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 004/132] autocomplete: rework tag autocomplete behavior. Reworks tag autocomplete to work the same way for all users. Previously autocomplete for Builders worked differently than autocomplete for regular users. This is how it works now: * If the search starts with a slash (/), then do a tag abbreviation match. For example, `/evth` matches eyebrows_visible_through_hair. * Otherwise if the search contains a wildcard (*), then just do a simple wildcard search. * Otherwise do a tag prefix match against tags and aliases. For example, `black` matches all tags or aliases beginning with `black`. * If the tag prefix match returns no results, then do a autocorrect match. The differences for regular users: * You can abbreviate tags with a slash (/). The differences for Builders: * Now tag abbreviations have to start with a slash (/). * Autocorrect isn't performed unless a regular search returns no results. * Results are always sorted by tag count. Before different types of results (regular tag matches, alias matches, abbreviation matches, and autocorrect matches) were all mixed together based on a tag weighting scheme. --- app/helpers/tags_helper.rb | 8 ------ app/logical/autocomplete_service.rb | 39 ++++++++++++++++++++++++-- app/models/tag.rb | 21 +++++++++++++- app/views/tags/index.html.erb | 2 +- test/unit/autocomplete_service_test.rb | 31 ++++++++++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index 53f0e01e7..d6e8a9728 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -3,12 +3,4 @@ module TagsHelper return nil if tag.blank? "tag-type-#{tag.category}" end - - def tag_alias_for_pattern(tag, pattern) - return nil if pattern.blank? - - tag.consequent_aliases.find do |tag_alias| - !tag.name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern) - end - end end diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 4896bf225..d05cb6696 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -63,10 +63,45 @@ class AutocompleteService end def autocomplete_tag(string) - tags = Tag.names_matches_with_aliases(string, limit) + if string.starts_with?("/") + string = string + "*" unless string.include?("*") + results = tag_matches(string) + results += tag_abbreviation_matches(string) + results = results.uniq.sort_by { |r| [r[:antecedent].length, -r[:post_count]] }.take(limit) + elsif string.include?("*") + results = tag_matches(string) + else + results = tag_matches(string + "*") + results = tag_autocorrect_matches(string) if results.blank? + end + + results + end + + def tag_matches(string) + name_matches = Tag.nonempty.name_matches(string).order(post_count: :desc).limit(limit) + alias_matches = Tag.nonempty.alias_matches(string).order(post_count: :desc).limit(limit) + union = "((#{name_matches.to_sql}) UNION (#{alias_matches.to_sql})) AS tags" + tags = Tag.from(union).order(post_count: :desc).limit(limit).includes(:consequent_aliases) tags.map do |tag| - { type: "tag", label: tag.name.tr("_", " "), value: tag.name, antecedent: tag.antecedent_name, category: tag.category, post_count: tag.post_count, source: nil, weight: nil } + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: tag.tag_alias_for_pattern(string)&.antecedent_name } + end + end + + def tag_abbreviation_matches(string) + tags = Tag.nonempty.abbreviation_matches(string).order(post_count: :desc).limit(limit) + + tags.map do |tag| + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation } + end + end + + def tag_autocorrect_matches(string) + tags = Tag.nonempty.fuzzy_name_matches(string).order_similarity(string).limit(limit) + + tags.map do |tag| + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count } end end diff --git a/app/models/tag.rb b/app/models/tag.rb index c9ca52da9..899718add 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,4 +1,6 @@ class Tag < ApplicationRecord + ABBREVIATION_REGEXP = /([a-z0-9])[a-z0-9']*($|[^a-z0-9']+)/ + has_one :wiki_page, :foreign_key => "title", :primary_key => "name" has_one :artist, :foreign_key => "name", :primary_key => "name" has_one :antecedent_alias, -> {active}, :class_name => "TagAlias", :foreign_key => "antecedent_name", :primary_key => "name" @@ -249,7 +251,7 @@ class Tag < ApplicationRecord end def alias_matches(name) - where(name: TagAlias.active.where_ilike(:antecedent_name, normalize_name(name)).select(:consequent_name)) + where(name: TagAlias.active.where_like(:antecedent_name, normalize_name(name)).select(:consequent_name)) end def name_or_alias_matches(name) @@ -260,6 +262,11 @@ class Tag < ApplicationRecord nonempty.name_matches(tag).order(post_count: :desc, name: :asc).limit(limit).pluck(:name) end + def abbreviation_matches(abbrev) + abbrev = abbrev.delete_prefix("/") + where("regexp_replace(tags.name, ?, '\\1', 'g') LIKE ?", ABBREVIATION_REGEXP.source, abbrev.to_escaped_for_sql_like) + end + def search(params) q = super @@ -352,6 +359,18 @@ class Tag < ApplicationRecord Post.system_tag_match(name) end + def abbreviation + name.gsub(ABBREVIATION_REGEXP, "\\1") + end + + def tag_alias_for_pattern(pattern) + return nil if pattern.blank? + + consequent_aliases.find do |tag_alias| + !name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern) + end + end + def self.model_restriction(table) super.where(table[:post_count].gt(0)) end diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb index 8648b1466..d884a56cb 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -10,7 +10,7 @@ <%= link_to_wiki "?", tag.name, class: tag_class(tag) %> <%= link_to tag.name, posts_path(tags: tag.name), class: tag_class(tag) %> - <% tag_alias = tag_alias_for_pattern(tag, params[:search][:name_or_alias_matches]) %> + <% tag_alias = tag.tag_alias_for_pattern(params[:search][:name_or_alias_matches]) %> <% if tag_alias.present? %> ← <%= link_to tag_alias.antecedent_name, tag_alias, class: "fineprint" %> <% end %> diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index fdcf7aeb7..8379bb295 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -80,6 +80,37 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("touhou", "~tou", :tag_query) end + should "autocomplete tag abbreviations" do + create(:tag, name: "mole", post_count: 150) + create(:tag, name: "mole_under_eye", post_count: 100) + create(:tag, name: "mole_under_mouth", post_count: 50) + + assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "/m", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "/mu", :tag_query) + assert_autocomplete_equals(%w[mole_under_mouth], "/mum", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/mue", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/*ue", :tag_query) + + assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) + assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) + end + + should "autocomplete wildcard searches" do + create(:tag, name: "mole", post_count: 150) + create(:tag, name: "mole_under_eye", post_count: 100) + create(:tag, name: "mole_under_mouth", post_count: 50) + + assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "mole*", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "*under*", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "*eye", :tag_query) + end + + should "autocorrect misspelled tags" do + create(:tag, name: "touhou") + + assert_autocomplete_equals(%w[touhou], "touhuo", :tag_query) + end + should "autocomplete static metatags" do assert_autocomplete_equals(["status:active"], "status:act", :tag_query) assert_autocomplete_equals(["parent:active"], "parent:act", :tag_query) From d6a5b9e2527613bd8f4c4677aac2ec9e65e0f3c8 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 005/132] autocomplete: rework cache policy. The previous cache policy was that all autocomplete results were cached for a fixed 7 days. The new policy is that if autocomplete returns more than 10 results they're cached for 24 hours, otherwise if it returns less than 10 results they're cached for 1 hour. The rationale is that if autocomplete returns a lot of results, then the top 10 results are relatively stable and unlikely to change, but if it returns less than 10 results, then the results are unstable and can be easily changed. We also change it so that autocomplete calls can be cached publicly. Public caching means that HTTP requests are cached by Cloudflare. This will ideally reduce load on the server and reduce latency for end users. This is only safe for calls that return the same results for all users (i.e. the results don't depend on the current user), since the cache is publicly shared by all users. Currently username, favgroup, and saved search autocomplete results depend on the current user, so they can't be publicly cached. --- app/controllers/autocomplete_controller.rb | 4 ++++ app/logical/autocomplete_service.rb | 27 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 68c46d793..0b36168db 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -8,6 +8,10 @@ class AutocompleteController < ApplicationController @autocomplete = AutocompleteService.new(@query, @type, current_user: CurrentUser.user, limit: @limit) @results = @autocomplete.autocomplete_results + @expires_in = @autocomplete.cache_duration + @public = @autocomplete.cache_publicly? + + expires_in @expires_in, public: @public unless response.cache_control.present? respond_with(@results) end end diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index d05cb6696..8ae91392d 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -1,4 +1,6 @@ class AutocompleteService + extend Memoist + POST_STATUSES = %w[active deleted pending flagged appealed banned modqueue unmoderated] STATIC_METATAGS = { @@ -197,4 +199,29 @@ class AutocompleteService results = autocomplete_tag(string).map { |result| result[:value] } [query, results] end + + def cache_duration + if autocomplete_results.size == limit + 24.hours + else + 1.hour + end + end + + # Queries that don't depend on the current user are safe to cache publicly. + def cache_publicly? + if type == :tag_query && parsed_search&.type == :tag + true + elsif type.in?(%i[tag artist wiki_page pool opensearch]) + true + else + false + end + end + + def parsed_search + PostQueryBuilder.new(query).terms.first + end + + memoize :autocomplete_results end From 119268e118f64bb45b8e2700b4b44c2cf726f6a4 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 006/132] autocomplete: fix exception when completing saved search labels. Fix an exception that was thrown when trying to autocomplete saved search labels (e.g. `search:all`) as an anonymous user. This was a pre-existing bug. --- app/logical/autocomplete_service.rb | 3 ++- app/models/saved_search.rb | 8 ++++++++ test/unit/autocomplete_service_test.rb | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 8ae91392d..8ec4d6a23 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -155,7 +155,8 @@ class AutocompleteService end def autocomplete_saved_search_label(string) - labels = SavedSearch.search_labels(current_user.id, label: string).take(limit) + string = "*" + string + "*" unless string.include?("*") + labels = current_user.saved_searches.labels_like(string).take(limit) labels.map do |label| { label: label.tr("_", " "), value: label } diff --git a/app/models/saved_search.rb b/app/models/saved_search.rb index e384da9a1..9036608a9 100644 --- a/app/models/saved_search.rb +++ b/app/models/saved_search.rb @@ -63,6 +63,14 @@ class SavedSearch < ApplicationRecord .gsub(/[[:space:]]/, "_") end + def all_labels + select(Arel.sql("distinct unnest(labels) as label")).order(:label) + end + + def labels_like(label) + all_labels.select { |ss| ss.label.ilike?(label) }.map(&:label) + end + def search_labels(user_id, params) labels = labels_for(user_id) diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index 8379bb295..29d8ea6ce 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -61,8 +61,10 @@ class AutocompleteServiceTest < ActiveSupport::TestCase create(:saved_search, query: "bkub", labels: ["artists"], user: user) assert_autocomplete_equals(["artists"], "art", :saved_search_label, current_user: user) + assert_autocomplete_equals([], "art", :saved_search_label, current_user: User.anonymous) assert_autocomplete_equals(["search:artists"], "search:art", :tag_query, current_user: user) + assert_autocomplete_equals([], "search:art", :saved_search_label, current_user: User.anonymous) end should "autocomplete single tags" do From 6a46aeb55cebeccc5cfb03ed972435812d56cd4d Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 007/132] autocomplete: tune autocorrect algorithm. Tune autocorrect to produce fewer false positives. Before we used trigram similarity. Now we use Levenshtein edit distance with a dynamic typo threshold. Trigram similarity was able to correct large transpositions (e.g. `miku_hatsune` -> `hatsune_miku`), but it was bad at correcting small typos. Levenshtein is good at small typos, but can't correct large transpositions. --- app/logical/autocomplete_service.rb | 2 +- app/models/tag.rb | 11 +++++++---- ...1213052805_add_extension_fuzzy_str_match.rb | 5 +++++ db/structure.sql | 18 +++++++++++++++++- test/functional/tags_controller_test.rb | 2 +- 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20201213052805_add_extension_fuzzy_str_match.rb diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 8ec4d6a23..33f5b6757 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -100,7 +100,7 @@ class AutocompleteService end def tag_autocorrect_matches(string) - tags = Tag.nonempty.fuzzy_name_matches(string).order_similarity(string).limit(limit) + tags = Tag.nonempty.autocorrect_matches(string).limit(limit) tags.map do |tag| { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count } diff --git a/app/models/tag.rb b/app/models/tag.rb index 899718add..3c3a3bf65 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -234,16 +234,19 @@ class Tag < ApplicationRecord end module SearchMethods + def autocorrect_matches(name) + tags = fuzzy_name_matches(name).order_similarity(name) + end + # ref: https://www.postgresql.org/docs/current/static/pgtrgm.html#idm46428634524336 def order_similarity(name) - # trunc(3 * sim) reduces the similarity score from a range of 0.0 -> 1.0 to just 0, 1, or 2. - # This groups tags first by approximate similarity, then by largest tags within groups of similar tags. - order(Arel.sql("trunc(3 * similarity(name, #{connection.quote(name)})) DESC"), "post_count DESC", "name DESC") + order(Arel.sql("levenshtein(left(name, 255), #{connection.quote(name)}), tags.post_count DESC, tags.name ASC")) end # ref: https://www.postgresql.org/docs/current/static/pgtrgm.html#idm46428634524336 def fuzzy_name_matches(name) - where("tags.name % ?", name) + max_distance = [name.size / 4, 3].max.floor.to_i + where("tags.name % ?", name).where("levenshtein(left(name, 255), ?) < ?", name, max_distance) end def name_matches(name) diff --git a/db/migrate/20201213052805_add_extension_fuzzy_str_match.rb b/db/migrate/20201213052805_add_extension_fuzzy_str_match.rb new file mode 100644 index 000000000..bea09b809 --- /dev/null +++ b/db/migrate/20201213052805_add_extension_fuzzy_str_match.rb @@ -0,0 +1,5 @@ +class AddExtensionFuzzyStrMatch < ActiveRecord::Migration[6.0] + def change + enable_extension "fuzzystrmatch" + end +end diff --git a/db/structure.sql b/db/structure.sql index 7da011e05..d85f9a64c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9,6 +9,21 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; + +-- +-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch WITH SCHEMA public; + + +-- +-- Name: EXTENSION fuzzystrmatch; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION fuzzystrmatch IS 'determine similarities and distance between strings'; + + -- -- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: - -- @@ -7420,6 +7435,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200520060951'), ('20200803022359'), ('20200816175151'), -('20201201211748'); +('20201201211748'), +('20201213052805'); diff --git a/test/functional/tags_controller_test.rb b/test/functional/tags_controller_test.rb index 56dead38c..797412d90 100644 --- a/test/functional/tags_controller_test.rb +++ b/test/functional/tags_controller_test.rb @@ -58,7 +58,7 @@ class TagsControllerTest < ActionDispatch::IntegrationTest should respond_to_search(name_matches: "hatsune_miku").with { @miku } should respond_to_search(name_normalize: "HATSUNE_MIKU ").with { @miku } should respond_to_search(name_or_alias_matches: "miku").with { @miku } - should respond_to_search(fuzzy_name_matches: "miku_hatsune", order: "similarity").with { @miku } + should respond_to_search(fuzzy_name_matches: "hatsune_mika", order: "similarity").with { @miku } should respond_to_search(name: "empty", hide_empty: "true").with { [] } should respond_to_search(name: "empty", hide_empty: "false").with { [@empty] } From b002bf25f58640e1fe1586546d5202374cab9ca0 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH 008/132] autocomplete: display autocorrected tags like aliases. Display autocorrected tags similar to aliases, with an arrow pointing at the corrected tag, but with a dotted underline beneath the misspelled tag to indicate that it's misspelled. --- app/javascript/src/javascripts/autocomplete.js.erb | 2 ++ app/javascript/src/styles/common/autocomplete.scss | 4 ++++ app/logical/autocomplete_service.rb | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index 1c81f3651..2d4818a95 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -182,6 +182,8 @@ Autocomplete.render_item = function(list, item) { if (item.type === "tag") { $link.addClass("tag-type-" + item.category); + } else if (item.type === "tag_autocorrect") { + $link.addClass(`tag-type-${item.category} tag-type-autocorrect`); } else if (item.type === "user") { var level_class = "user-" + item.level.toLowerCase(); $link.addClass(level_class); diff --git a/app/javascript/src/styles/common/autocomplete.scss b/app/javascript/src/styles/common/autocomplete.scss index 6ca29663e..44158a614 100644 --- a/app/javascript/src/styles/common/autocomplete.scss +++ b/app/javascript/src/styles/common/autocomplete.scss @@ -21,4 +21,8 @@ .autocomplete-arrow { color: var(--autocomplete-arrow-color); } + + a.tag-type-autocorrect .autocomplete-antecedent { + text-decoration: dotted underline; + } } diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 33f5b6757..833c016da 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -103,7 +103,7 @@ class AutocompleteService tags = Tag.nonempty.autocorrect_matches(string).limit(limit) tags.map do |tag| - { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count } + { type: "tag_autocorrect", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: string } end end From 71ba45c57ca4e8774d6bdf224809f5cdc34037c1 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:52:30 -0600 Subject: [PATCH 009/132] forum: fix /forum_posts?search[linked_to] not normalizing tags. Fix searches like https://danbooru.donmai.us/forum_posts?search[linked_to]=touhou%20 not working because the tag wasn't normalized. --- app/models/forum_post.rb | 6 +++++- test/functional/forum_posts_controller_test.rb | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/models/forum_post.rb b/app/models/forum_post.rb index 62815ff62..2558a33ce 100644 --- a/app/models/forum_post.rb +++ b/app/models/forum_post.rb @@ -36,13 +36,17 @@ class ForumPost < ApplicationRecord where(topic_id: ForumTopic.visible(user)) end + def wiki_link_matches(title) + where(id: DtextLink.forum_post.wiki_link.where(link_target: WikiPage.normalize_title(title)).select(:model_id)) + end + def search(params) q = super q = q.search_attributes(params, :is_deleted, :body) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :text_index) if params[:linked_to].present? - q = q.where(id: DtextLink.forum_post.wiki_link.where(link_target: params[:linked_to]).select(:model_id)) + q = q.wiki_link_matches(params[:linked_to]) end q.apply_default_order(params) diff --git a/test/functional/forum_posts_controller_test.rb b/test/functional/forum_posts_controller_test.rb index 41cde00d0..be3912546 100644 --- a/test/functional/forum_posts_controller_test.rb +++ b/test/functional/forum_posts_controller_test.rb @@ -76,6 +76,7 @@ class ForumPostsControllerTest < ActionDispatch::IntegrationTest should respond_to_search(body_matches: "xxx").with { @forum_post } should respond_to_search(body_matches: "bababa").with { [] } should respond_to_search(is_deleted: "true").with { @unrelated_forum } + should respond_to_search(linked_to: "TEST").with { @other_forum } context "using includes" do should respond_to_search(topic: {title_matches: "my forum topic"}).with { @forum_post } From 61e7d32f78d70110965c551b8098f64987961cfd Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 02:12:20 -0600 Subject: [PATCH 010/132] tests: fix FC2 artist normalization url test. --- test/unit/artist_url_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/artist_url_test.rb b/test/unit/artist_url_test.rb index fc4f22ba4..c9301eecf 100644 --- a/test/unit/artist_url_test.rb +++ b/test/unit/artist_url_test.rb @@ -120,11 +120,11 @@ class ArtistUrlTest < ActiveSupport::TestCase should "normalize fc2 urls" do url = FactoryBot.create(:artist_url, :url => "http://blog55.fc2.com/monet") assert_equal("http://blog55.fc2.com/monet", url.url) - assert_equal("http://blog.fc2.com/monet/", url.normalized_url) + assert_equal("http://monet.blog.fc2.com/", url.normalized_url) url = FactoryBot.create(:artist_url, :url => "http://blog-imgs-55.fc2.com/monet") assert_equal("http://blog-imgs-55.fc2.com/monet", url.url) - assert_equal("http://blog.fc2.com/monet/", url.normalized_url) + assert_equal("http://monet.blog.fc2.com/", url.normalized_url) end should "normalize deviant art artist urls" do From 8f1d8e2c56e3afbf1f3d003de527546632a84453 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 02:14:57 -0600 Subject: [PATCH 011/132] mailers: fix Rails 6.1 incompatibility. `add_template_helper` is removed in Rails 6.1. --- app/mailers/user_mailer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 787ccf252..b15c195b9 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -1,6 +1,6 @@ class UserMailer < ApplicationMailer - add_template_helper ApplicationHelper - add_template_helper UsersHelper + helper :application + helper :users def dmail_notice(dmail) @dmail = dmail From 62b69eb133b18265bdd1128114ac95cfc8c72966 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 03:38:07 -0600 Subject: [PATCH 012/132] gems: upgrade http-cookie to fix Rails 6.1 bug. Upgrade the http-cookie gem to a personal fork containing a bugfix for a http-cookie bug that is triggered by Rails 6.1. The bug is that HTTP::Cookie objects raise an exception if they're compared against non-cookie objects. This bug gets triggered when the Nijie source strategy calls `Rails.cache.fetch` to cache the Nijie login cookie. `Rails.cache.fetch` ends up calling ActiveSupport::Cache::Store::Entry#dup_value!, which compares the cookie with `true`, which triggers the exception. The http-cookie gem hasn't been updated for 4 years, so we're stuck patching the library ourselves. --- Gemfile | 1 + Gemfile.lock | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 9f109c6ae..1aef4acc1 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,7 @@ gem 'scenic' gem 'ipaddress_2' gem 'http' gem 'activerecord-hierarchical_query' +gem 'http-cookie', git: "https://github.com/danbooru/http-cookie" gem 'pundit' gem 'mail' gem 'nokogiri' diff --git a/Gemfile.lock b/Gemfile.lock index 66cc5f2ca..e93fe7071 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/danbooru/http-cookie + revision: 382d8a641e4df226e0e7b0d2bfaeadb2fe71dd84 + specs: + http-cookie (1.0.4) + domain_name (~> 0.5) + GIT remote: https://github.com/evazion/dtext_rb.git revision: a95bf1d537cbdba4585adb8e123f03f001f56fd7 @@ -161,8 +168,6 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) http-parser (~> 1.2.0) - http-cookie (1.0.3) - domain_name (~> 0.5) http-form_data (2.3.0) http-parser (1.2.2) ffi-compiler @@ -405,6 +410,7 @@ DEPENDENCIES ffaker flamegraph http + http-cookie! ipaddress_2 listen mail From 8d87b1a0c0d2df2d3cc80934e8b49deb22e39ba2 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 04:00:32 -0600 Subject: [PATCH 013/132] models: fix deprecated `errors[:base] << "message"` calls. Replace the idiom `errors[:base] << "message"` with `errors.add(:base, "message")`. The former is deprecated in Rails 6.1. --- app/controllers/emails_controller.rb | 2 +- app/controllers/passwords_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/logical/bulk_update_request_processor.rb | 18 ++++++------- app/logical/tag_name_validator.rb | 28 ++++++++++---------- app/logical/user_deletion.rb | 4 +-- app/logical/user_name_validator.rb | 10 +++---- app/models/artist.rb | 2 +- app/models/artist_url.rb | 6 ++--- app/models/ban.rb | 2 +- app/models/bulk_update_request.rb | 2 +- app/models/comment_vote.rb | 2 +- app/models/dmail.rb | 2 +- app/models/email_address.rb | 2 +- app/models/favorite_group.rb | 8 +++--- app/models/ip_ban.rb | 14 +++++----- app/models/note.rb | 4 +-- app/models/pool.rb | 20 +++++++------- app/models/post.rb | 25 +++++++++-------- app/models/post_appeal.rb | 4 +-- app/models/post_disapproval.rb | 2 +- app/models/post_flag.rb | 10 +++---- app/models/saved_search.rb | 2 +- app/models/tag_alias.rb | 2 +- app/models/tag_implication.rb | 20 +++++++------- app/models/tag_relationship.rb | 2 +- app/models/upload.rb | 14 +++++----- app/models/user_name_change_request.rb | 2 +- app/models/wiki_page.rb | 4 +-- 29 files changed, 108 insertions(+), 109 deletions(-) diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb index d9b35a46e..37b385e48 100644 --- a/app/controllers/emails_controller.rb +++ b/app/controllers/emails_controller.rb @@ -17,7 +17,7 @@ class EmailsController < ApplicationController if @user.authenticate_password(params[:user][:password]) @user.update(email_address_attributes: { address: params[:user][:email] }) else - @user.errors[:base] << "Password was incorrect" + @user.errors.add(:base, "Password was incorrect") end if @user.errors.none? diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 1a63c5b8a..0e78f9c2e 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -12,7 +12,7 @@ class PasswordsController < ApplicationController if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id]) @user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation]) else - @user.errors[:base] << "Incorrect password" + @user.errors.add(:base, "Incorrect password") end flash[:notice] = @user.errors.none? ? "Password updated" : @user.errors.full_messages.join("; ") diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 71451d04d..e18888579 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -80,7 +80,7 @@ class UsersController < ApplicationController flash[:notice] = "Sign up failed" elsif @user.email_address&.invalid?(:deliverable) flash[:notice] = "Sign up failed: email address is invalid or doesn't exist" - @user.errors[:base] << @user.email_address.errors.full_messages.join("; ") + @user.errors.add(:base, @user.email_address.errors.full_messages.join("; ")) elsif !@user.save flash[:notice] = "Sign up failed: #{@user.errors.full_messages.join("; ")}" else diff --git a/app/logical/bulk_update_request_processor.rb b/app/logical/bulk_update_request_processor.rb index 25b96918e..bb4ef8271 100644 --- a/app/logical/bulk_update_request_processor.rb +++ b/app/logical/bulk_update_request_processor.rb @@ -55,20 +55,20 @@ class BulkUpdateRequestProcessor tag_alias = TagAlias.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1]) tag_alias.save(context: validation_context) if tag_alias.errors.present? - errors[:base] << "Can't create alias #{tag_alias.antecedent_name} -> #{tag_alias.consequent_name} (#{tag_alias.errors.full_messages.join("; ")})" + errors.add(:base, "Can't create alias #{tag_alias.antecedent_name} -> #{tag_alias.consequent_name} (#{tag_alias.errors.full_messages.join("; ")})") end when :create_implication tag_implication = TagImplication.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1], status: "active") tag_implication.save(context: validation_context) if tag_implication.errors.present? - errors[:base] << "Can't create implication #{tag_implication.antecedent_name} -> #{tag_implication.consequent_name} (#{tag_implication.errors.full_messages.join("; ")})" + errors.add(:base, "Can't create implication #{tag_implication.antecedent_name} -> #{tag_implication.consequent_name} (#{tag_implication.errors.full_messages.join("; ")})") end when :remove_alias tag_alias = TagAlias.active.find_by(antecedent_name: args[0], consequent_name: args[1]) if tag_alias.nil? - errors[:base] << "Can't remove alias #{args[0]} -> #{args[1]} (alias doesn't exist)" + errors.add(:base, "Can't remove alias #{args[0]} -> #{args[1]} (alias doesn't exist)") else tag_alias.update(status: "deleted") end @@ -76,7 +76,7 @@ class BulkUpdateRequestProcessor when :remove_implication tag_implication = TagImplication.active.find_by(antecedent_name: args[0], consequent_name: args[1]) if tag_implication.nil? - errors[:base] << "Can't remove implication #{args[0]} -> #{args[1]} (implication doesn't exist)" + errors.add(:base, "Can't remove implication #{args[0]} -> #{args[1]} (implication doesn't exist)") else tag_implication.update(status: "deleted") end @@ -84,22 +84,22 @@ class BulkUpdateRequestProcessor when :change_category tag = Tag.find_by_name(args[0]) if tag.nil? - errors[:base] << "Can't change category #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)" + errors.add(:base, "Can't change category #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)") end when :rename tag = Tag.find_by_name(args[0]) if tag.nil? - errors[:base] << "Can't rename #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)" + errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)") elsif tag.post_count > MAXIMUM_RENAME_COUNT - errors[:base] << "Can't rename #{args[0]} -> #{args[1]} ('#{args[0]}' has more than #{MAXIMUM_RENAME_COUNT} posts, use an alias instead)" + errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} ('#{args[0]}' has more than #{MAXIMUM_RENAME_COUNT} posts, use an alias instead)") end when :mass_update, :nuke # okay when :invalid_line - errors[:base] << "Invalid line: #{args[0]}" + errors.add(:base, "Invalid line: #{args[0]}") else # should never happen @@ -113,7 +113,7 @@ class BulkUpdateRequestProcessor def validate_script_length if commands.size > MAXIMUM_SCRIPT_LENGTH - errors[:base] << "Bulk update request is too long (maximum size: #{MAXIMUM_SCRIPT_LENGTH} lines). Split your request into smaller chunks and try again." + errors.add(:base, "Bulk update request is too long (maximum size: #{MAXIMUM_SCRIPT_LENGTH} lines). Split your request into smaller chunks and try again.") end end diff --git a/app/logical/tag_name_validator.rb b/app/logical/tag_name_validator.rb index b4886f33d..10c1d4b67 100644 --- a/app/logical/tag_name_validator.rb +++ b/app/logical/tag_name_validator.rb @@ -2,37 +2,37 @@ class TagNameValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) case Tag.normalize_name(value) when /\A_*\z/ - record.errors[attribute] << "'#{value}' cannot be blank" + record.errors.add(attribute, "'#{value}' cannot be blank") when /\*/ - record.errors[attribute] << "'#{value}' cannot contain asterisks ('*')" + record.errors.add(attribute, "'#{value}' cannot contain asterisks ('*')") when /,/ - record.errors[attribute] << "'#{value}' cannot contain commas (',')" + record.errors.add(attribute, "'#{value}' cannot contain commas (',')") when /\A~/ - record.errors[attribute] << "'#{value}' cannot begin with a tilde ('~')" + record.errors.add(attribute, "'#{value}' cannot begin with a tilde ('~')") when /\A-/ - record.errors[attribute] << "'#{value}' cannot begin with a dash ('-')" + record.errors.add(attribute, "'#{value}' cannot begin with a dash ('-')") when /\A_/ - record.errors[attribute] << "'#{value}' cannot begin with an underscore" + record.errors.add(attribute, "'#{value}' cannot begin with an underscore") when /_\z/ - record.errors[attribute] << "'#{value}' cannot end with an underscore" + record.errors.add(attribute, "'#{value}' cannot end with an underscore") when /__/ - record.errors[attribute] << "'#{value}' cannot contain consecutive underscores" + record.errors.add(attribute, "'#{value}' cannot contain consecutive underscores") when /[^[:graph:]]/ - record.errors[attribute] << "'#{value}' cannot contain non-printable characters" + record.errors.add(attribute, "'#{value}' cannot contain non-printable characters") when /[^[:ascii:]]/ - record.errors[attribute] << "'#{value}' must consist of only ASCII characters" + record.errors.add(attribute, "'#{value}' must consist of only ASCII characters") when /\A(#{PostQueryBuilder::METATAGS.join("|")}):(.+)\z/i - record.errors[attribute] << "'#{value}' cannot begin with '#{$1}:'" + record.errors.add(attribute, "'#{value}' cannot begin with '#{$1}:'") when /\A(#{Tag.categories.regexp}):(.+)\z/i - record.errors[attribute] << "'#{value}' cannot begin with '#{$1}:'" + record.errors.add(attribute, "'#{value}' cannot begin with '#{$1}:'") when "new", "search" - record.errors[attribute] << "'#{value}' is a reserved name and cannot be used" + record.errors.add(attribute, "'#{value}' is a reserved name and cannot be used") when /\A(.+)_\(cosplay\)\z/i tag_name = TagAlias.to_aliased([$1]).first tag = Tag.find_by_name(tag_name) if tag.present? && !tag.empty? && !tag.character? - record.errors[attribute] << "#{tag_name} must be a character tag" + record.errors.add(attribute, "#{tag_name} must be a character tag") end end end diff --git a/app/logical/user_deletion.rb b/app/logical/user_deletion.rb index 8577f19a1..0ae9cc3fa 100644 --- a/app/logical/user_deletion.rb +++ b/app/logical/user_deletion.rb @@ -61,11 +61,11 @@ class UserDeletion def validate_deletion if !user.authenticate_password(password) - errors[:base] << "Password is incorrect" + errors.add(:base, "Password is incorrect") end if user.level >= User::Levels::ADMIN - errors[:base] << "Admins cannot delete their account" + errors.add(:base, "Admins cannot delete their account") end end end diff --git a/app/logical/user_name_validator.rb b/app/logical/user_name_validator.rb index 93bd4c9d7..2df2decf4 100644 --- a/app/logical/user_name_validator.rb +++ b/app/logical/user_name_validator.rb @@ -2,10 +2,10 @@ class UserNameValidator < ActiveModel::EachValidator def validate_each(rec, attr, value) name = value - rec.errors[attr] << "already exists" if User.find_by_name(name).present? - rec.errors[attr] << "must be 2 to 100 characters long" if !name.length.between?(2, 100) - rec.errors[attr] << "cannot have whitespace or colons" if name =~ /[[:space:]]|:/ - rec.errors[attr] << "cannot begin or end with an underscore" if name =~ /\A_|_\z/ - rec.errors[attr] << "is not allowed" if name =~ Regexp.union(Danbooru.config.user_name_blacklist) + rec.errors.add(attr, "already exists") if User.find_by_name(name).present? + rec.errors.add(attr, "must be 2 to 100 characters long") if !name.length.between?(2, 100) + rec.errors.add(attr, "cannot have whitespace or colons") if name =~ /[[:space:]]|:/ + rec.errors.add(attr, "cannot begin or end with an underscore") if name =~ /\A_|_\z/ + rec.errors.add(attr, "is not allowed") if name =~ Regexp.union(Danbooru.config.user_name_blacklist) end end diff --git a/app/models/artist.rb b/app/models/artist.rb index abc7260bf..6a2222c65 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -156,7 +156,7 @@ class Artist < ApplicationRecord return unless !is_deleted? && name_changed? && tag.present? if tag.category_name != "Artist" && !tag.empty? - errors[:base] << "'#{name}' is a #{tag.category_name.downcase} tag; artist entries can only be created for artist tags" + errors.add(:base, "'#{name}' is a #{tag.category_name.downcase} tag; artist entries can only be created for artist tags") end end diff --git a/app/models/artist_url.rb b/app/models/artist_url.rb index 625357dac..bab5e04c3 100644 --- a/app/models/artist_url.rb +++ b/app/models/artist_url.rb @@ -113,11 +113,11 @@ class ArtistUrl < ApplicationRecord end def validate_scheme(uri) - errors[:url] << "'#{uri}' must begin with http:// or https:// " unless uri.scheme.in?(%w[http https]) + errors.add(:url, "'#{uri}' must begin with http:// or https:// ") unless uri.scheme.in?(%w[http https]) end def validate_hostname(uri) - errors[:url] << "'#{uri}' has a hostname '#{uri.host}' that does not contain a dot" unless uri.host&.include?('.') + errors.add(:url, "'#{uri}' has a hostname '#{uri.host}' that does not contain a dot") unless uri.host&.include?('.') end def validate_url_format @@ -125,7 +125,7 @@ class ArtistUrl < ApplicationRecord validate_scheme(uri) validate_hostname(uri) rescue Addressable::URI::InvalidURIError => error - errors[:url] << "'#{uri}' is malformed: #{error}" + errors.add(:url, "'#{uri}' is malformed: #{error}") end def self.searchable_includes diff --git a/app/models/ban.rb b/app/models/ban.rb index 408a33306..4d91d04ae 100644 --- a/app/models/ban.rb +++ b/app/models/ban.rb @@ -45,7 +45,7 @@ class Ban < ApplicationRecord end def validate_user_is_bannable - self.errors[:user] << "is already banned" if user.is_banned? + errors.add(:user, "is already banned") if user.is_banned? end def update_user_on_create diff --git a/app/models/bulk_update_request.rb b/app/models/bulk_update_request.rb index 2149b564f..b42539011 100644 --- a/app/models/bulk_update_request.rb +++ b/app/models/bulk_update_request.rb @@ -97,7 +97,7 @@ class BulkUpdateRequest < ApplicationRecord def validate_script if processor.invalid?(:request) - errors[:base] << processor.errors.full_messages.join("; ") + errors.add(:base, processor.errors.full_messages.join("; ")) end end diff --git a/app/models/comment_vote.rb b/app/models/comment_vote.rb index ec1a5ce11..e44818f63 100644 --- a/app/models/comment_vote.rb +++ b/app/models/comment_vote.rb @@ -26,7 +26,7 @@ class CommentVote < ApplicationRecord def validate_comment_can_be_down_voted if is_positive? && comment.creator == CurrentUser.user - errors.add :base, "You cannot upvote your own comments" + errors.add(:base, "You cannot upvote your own comments") end end diff --git a/app/models/dmail.rb b/app/models/dmail.rb index a789384d9..e3885c236 100644 --- a/app/models/dmail.rb +++ b/app/models/dmail.rb @@ -158,7 +158,7 @@ class Dmail < ApplicationRecord return if from.blank? || from.is_gold? if from.dmails.where("created_at > ?", 1.hour.ago).group(:to).reorder(nil).count.size >= 10 - errors[:base] << "You can't send dmails to more than 10 users per hour" + errors.add(:base, "You can't send dmails to more than 10 users per hour") end end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 6267ff2bc..367af7a97 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -21,7 +21,7 @@ class EmailAddress < ApplicationRecord def validate_deliverable if EmailValidator.undeliverable?(address) - errors[:address] << "is invalid or does not exist" + errors.add(:address, "is invalid or does not exist") end end diff --git a/app/models/favorite_group.rb b/app/models/favorite_group.rb index edd4d0dfe..e34ef706b 100644 --- a/app/models/favorite_group.rb +++ b/app/models/favorite_group.rb @@ -56,13 +56,13 @@ class FavoriteGroup < ApplicationRecord if !creator.is_platinum? error += " Upgrade your account to create more." end - self.errors.add(:base, error) + errors.add(:base, error) end end def validate_number_of_posts if post_count > 10_000 - errors[:base] << "Favorite groups can have up to 10,000 posts each" + errors.add(:base, "Favorite groups can have up to 10,000 posts each") end end @@ -72,12 +72,12 @@ class FavoriteGroup < ApplicationRecord nonexisting_post_ids = added_post_ids - existing_post_ids if nonexisting_post_ids.present? - errors[:base] << "Cannot add invalid post(s) to favgroup: #{nonexisting_post_ids.to_sentence}" + errors.add(:base, "Cannot add invalid post(s) to favgroup: #{nonexisting_post_ids.to_sentence}") end duplicate_post_ids = post_ids.group_by(&:itself).transform_values(&:size).select { |id, count| count > 1 }.keys if duplicate_post_ids.present? - errors[:base] << "Favgroup already contains post #{duplicate_post_ids.to_sentence}" + errors.add(:base, "Favgroup already contains post #{duplicate_post_ids.to_sentence}") end end diff --git a/app/models/ip_ban.rb b/app/models/ip_ban.rb index 266ff0ea0..76b3b7c05 100644 --- a/app/models/ip_ban.rb +++ b/app/models/ip_ban.rb @@ -48,19 +48,19 @@ class IpBan < ApplicationRecord def validate_ip_addr if ip_addr.blank? - errors[:ip_addr] << "is invalid" + errors.add(:ip_addr, "is invalid") elsif ip_addr.private? || ip_addr.loopback? || ip_addr.link_local? - errors[:ip_addr] << "must be a public address" + errors.add(:ip_addr, "must be a public address") elsif full_ban? && ip_addr.ipv4? && ip_addr.prefix < 24 - errors[:ip_addr] << "may not have a subnet bigger than /24" + errors.add(:ip_addr, "may not have a subnet bigger than /24") elsif partial_ban? && ip_addr.ipv4? && ip_addr.prefix < 8 - errors[:ip_addr] << "may not have a subnet bigger than /8" + errors.add(:ip_addr, "may not have a subnet bigger than /8") elsif full_ban? && ip_addr.ipv6? && ip_addr.prefix < 64 - errors[:ip_addr] << "may not have a subnet bigger than /64" + errors.add(:ip_addr, "may not have a subnet bigger than /64") elsif partial_ban? && ip_addr.ipv6? && ip_addr.prefix < 20 - errors[:ip_addr] << "may not have a subnet bigger than /20" + errors.add(:ip_addr, "may not have a subnet bigger than /20") elsif new_record? && IpBan.active.ip_matches(subnetted_ip).exists? - errors[:ip_addr] << "is already banned" + errors.add(:ip_addr, "is already banned") end end diff --git a/app/models/note.rb b/app/models/note.rb index 4facb2028..3cb745da7 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -26,13 +26,13 @@ class Note < ApplicationRecord extend SearchMethods def validate_post_is_not_locked - errors[:post] << "is note locked" if post.is_note_locked? + errors.add(:post, "is note locked") if post.is_note_locked? end def note_within_image return false unless post.present? if x < 0 || y < 0 || (x > post.image_width) || (y > post.image_height) || width < 0 || height < 0 || (x + width > post.image_width) || (y + height > post.image_height) - self.errors.add(:note, "must be inside the image") + errors.add(:note, "must be inside the image") end end diff --git a/app/models/pool.rb b/app/models/pool.rb index e789f10a7..4c3f59e9d 100644 --- a/app/models/pool.rb +++ b/app/models/pool.rb @@ -147,7 +147,7 @@ class Pool < ApplicationRecord def updater_can_edit_deleted if is_deleted? && !Pundit.policy!([CurrentUser.user, nil], self).update? - errors[:base] << "You cannot update pools that are deleted" + errors.add(:base, "You cannot update pools that are deleted") end end @@ -254,23 +254,23 @@ class Pool < ApplicationRecord def validate_name case name when /\A(any|none|series|collection)\z/i - errors[:name] << "cannot be any of the following names: any, none, series, collection" + errors.add(:name, "cannot be any of the following names: any, none, series, collection") when /,/ - errors[:name] << "cannot contain commas" + errors.add(:name, "cannot contain commas") when /\*/ - errors[:name] << "cannot contain asterisks" + errors.add(:name, "cannot contain asterisks") when /\A_/ - errors[:name] << "cannot begin with an underscore" + errors.add(:name, "cannot begin with an underscore") when /_\z/ - errors[:name] << "cannot end with an underscore" + errors.add(:name, "cannot end with an underscore") when /__/ - errors[:name] << "cannot contain consecutive underscores" + errors.add(:name, "cannot contain consecutive underscores") when /[^[:graph:]]/ - errors[:name] << "cannot contain non-printable characters" + errors.add(:name, "cannot contain non-printable characters") when "" - errors[:name] << "cannot be blank" + errors.add(:name, "cannot be blank") when /\A[0-9]+\z/ - errors[:name] << "cannot contain only digits" + errors.add(:name, "cannot contain only digits") end end end diff --git a/app/models/post.rb b/app/models/post.rb index 5117102f2..6f54da31c 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -490,7 +490,7 @@ class Post < ApplicationRecord invalid_tags.each do |tag| tag.errors.messages.each do |attribute, messages| - warnings[:base] << "Couldn't add tag: #{messages.join(';')}" + warnings.add(:base, "Couldn't add tag: #{messages.join(';')}") end end @@ -976,7 +976,7 @@ class Post < ApplicationRecord module DeletionMethods def expunge! if is_status_locked? - self.errors.add(:is_status_locked, "; cannot delete post") + errors.add(:is_status_locked, "; cannot delete post") return false end @@ -1082,11 +1082,11 @@ class Post < ApplicationRecord def copy_notes_to(other_post, copy_tags: NOTE_COPY_TAGS) transaction do if id == other_post.id - errors.add :base, "Source and destination posts are the same" + errors.add(:base, "Source and destination posts are the same") return false end unless has_notes? - errors.add :post, "has no notes" + errors.add(:post, "has no notes") return false end @@ -1357,8 +1357,7 @@ class Post < ApplicationRecord module ValidationMethods def post_is_not_its_own_parent if !new_record? && id == parent_id - errors[:base] << "Post cannot have itself as a parent" - false + errors.add(:base, "Post cannot have itself as a parent") end end @@ -1372,7 +1371,7 @@ class Post < ApplicationRecord end def uploader_is_not_limited - errors[:uploader] << uploader.upload_limit.limit_reason if uploader.upload_limit.limited? + errors.add(:uploader, uploader.upload_limit.limit_reason) if uploader.upload_limit.limited? end def added_tags_are_valid @@ -1382,12 +1381,12 @@ class Post < ApplicationRecord if new_general_tags.present? n = new_general_tags.size tag_wiki_links = new_general_tags.map { |tag| "[[#{tag.name}]]" } - self.warnings[:base] << "Created #{n} new #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}" + warnings.add(:base, "Created #{n} new #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}") end new_artist_tags.each do |tag| if tag.artist.blank? - self.warnings[:base] << "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[/artists/new?artist%5Bname%5D=#{CGI.escape(tag.name)}]" + warnings.add(:base, "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[/artists/new?artist%5Bname%5D=#{CGI.escape(tag.name)}]") end end end @@ -1398,7 +1397,7 @@ class Post < ApplicationRecord if unremoved_tags.present? unremoved_tags_list = unremoved_tags.map { |t| "[[#{t}]]" }.to_sentence - self.warnings[:base] << "#{unremoved_tags_list} could not be removed. Check for implications and try again" + warnings.add(:base, "#{unremoved_tags_list} could not be removed. Check for implications and try again") end end @@ -1409,21 +1408,21 @@ class Post < ApplicationRecord return if tags.any?(&:artist?) return if Sources::Strategies.find(source).is_a?(Sources::Strategies::Null) - self.warnings[:base] << "Artist tag is required. \"Create new artist tag\":[/artists/new?artist%5Bsource%5D=#{CGI.escape(source)}]. Ask on the forum if you need naming help" + warnings.add(:base, "Artist tag is required. \"Create new artist tag\":[/artists/new?artist%5Bsource%5D=#{CGI.escape(source)}]. Ask on the forum if you need naming help") end def has_copyright_tag return if !new_record? return if has_tag?("copyright_request") || tags.any?(&:copyright?) - self.warnings[:base] << "Copyright tag is required. Consider adding [[copyright request]] or [[original]]" + warnings.add(:base, "Copyright tag is required. Consider adding [[copyright request]] or [[original]]") end def has_enough_tags return if !new_record? if tags.count(&:general?) < 10 - self.warnings[:base] << "Uploads must have at least 10 general tags. Read [[howto:tag]] for guidelines on tagging your uploads" + warnings.add(:base, "Uploads must have at least 10 general tags. Read [[howto:tag]] for guidelines on tagging your uploads") end end end diff --git a/app/models/post_appeal.rb b/app/models/post_appeal.rb index 8c8be655a..af54ce997 100644 --- a/app/models/post_appeal.rb +++ b/app/models/post_appeal.rb @@ -28,11 +28,11 @@ class PostAppeal < ApplicationRecord extend SearchMethods def validate_creator_is_not_limited - errors[:creator] << "have reached your appeal limit" if creator.is_appeal_limited? + errors.add(:creator, "have reached your appeal limit") if creator.is_appeal_limited? end def validate_post_is_appealable - errors[:post] << "cannot be appealed" if post.is_status_locked? || !post.is_appealable? + errors.add(:post, "cannot be appealed") if post.is_status_locked? || !post.is_appealable? end def self.searchable_includes diff --git a/app/models/post_disapproval.rb b/app/models/post_disapproval.rb index 0c6af91d8..08fac4921 100644 --- a/app/models/post_disapproval.rb +++ b/app/models/post_disapproval.rb @@ -51,7 +51,7 @@ class PostDisapproval < ApplicationRecord def validate_disapproval if post.is_active? - errors[:post] << "is already active and cannot be disapproved" + errors.add(:post, "is already active and cannot be disapproved") end end diff --git a/app/models/post_flag.rb b/app/models/post_flag.rb index 80f5b07f1..24493ba2f 100644 --- a/app/models/post_flag.rb +++ b/app/models/post_flag.rb @@ -95,17 +95,17 @@ class PostFlag < ApplicationRecord end def validate_creator_is_not_limited - errors[:creator] << "have reached your flag limit" if creator.is_flag_limited? && !is_deletion + errors.add(:creator, "have reached your flag limit") if creator.is_flag_limited? && !is_deletion end def validate_post - errors[:post] << "is pending and cannot be flagged" if post.is_pending? && !is_deletion - errors[:post] << "is deleted and cannot be flagged" if post.is_deleted? && !is_deletion - errors[:post] << "is locked and cannot be flagged" if post.is_status_locked? + errors.add(:post, "is pending and cannot be flagged") if post.is_pending? && !is_deletion + errors.add(:post, "is deleted and cannot be flagged") if post.is_deleted? && !is_deletion + errors.add(:post, "is locked and cannot be flagged") if post.is_status_locked? flag = post.flags.in_cooldown.last if !is_deletion && flag.present? - errors[:post] << "cannot be flagged more than once every #{Danbooru.config.moderation_period.inspect} (last flagged: #{flag.created_at.to_s(:long)})" + errors.add(:post, "cannot be flagged more than once every #{Danbooru.config.moderation_period.inspect} (last flagged: #{flag.created_at.to_s(:long)})") end end diff --git a/app/models/saved_search.rb b/app/models/saved_search.rb index 9036608a9..8ab9aba52 100644 --- a/app/models/saved_search.rb +++ b/app/models/saved_search.rb @@ -182,7 +182,7 @@ class SavedSearch < ApplicationRecord def validate_count if user.saved_searches.count >= user.max_saved_searches - self.errors[:user] << "can only have up to #{user.max_saved_searches} " + "saved search".pluralize(user.max_saved_searches) + errors.add(:user, "can only have up to #{user.max_saved_searches} " + "saved search".pluralize(user.max_saved_searches)) end end diff --git a/app/models/tag_alias.rb b/app/models/tag_alias.rb index 6657b3868..9c460e27a 100644 --- a/app/models/tag_alias.rb +++ b/app/models/tag_alias.rb @@ -23,7 +23,7 @@ class TagAlias < TagRelationship tag_alias = TagAlias.active.find_by(antecedent_name: consequent_name) if tag_alias.present? && tag_alias.consequent_name != antecedent_name - errors[:base] << "#{tag_alias.antecedent_name} is already aliased to #{tag_alias.consequent_name}" + errors.add(:base, "#{tag_alias.antecedent_name} is already aliased to #{tag_alias.consequent_name}") end end diff --git a/app/models/tag_implication.rb b/app/models/tag_implication.rb index 2564e06f8..df0e4be3a 100644 --- a/app/models/tag_implication.rb +++ b/app/models/tag_implication.rb @@ -64,7 +64,7 @@ class TagImplication < TagRelationship # We don't want a -> b -> a chains implied_tags = TagImplication.tags_implied_by(consequent_name).map(&:name) if implied_tags.include?(antecedent_name) - errors[:base] << "Tag implication can not create a circular relation with another tag implication" + errors.add(:base, "Tag implication can not create a circular relation with another tag implication") end end @@ -77,7 +77,7 @@ class TagImplication < TagRelationship implied_tags = implications.tags_implied_by(antecedent_name).map(&:name) if implied_tags.include?(consequent_name) - errors[:base] << "#{antecedent_name} already implies #{consequent_name} through another implication" + errors.add(:base, "#{antecedent_name} already implies #{consequent_name} through another implication") end end @@ -86,7 +86,7 @@ class TagImplication < TagRelationship # We don't want to implicate a -> b if a is already aliased to c if TagAlias.active.exists?(["antecedent_name = ?", antecedent_name]) - errors[:base] << "Antecedent tag must not be aliased to another tag" + errors.add(:base, "Antecedent tag must not be aliased to another tag") end end @@ -95,13 +95,13 @@ class TagImplication < TagRelationship # We don't want to implicate a -> b if b is already aliased to c if TagAlias.active.exists?(["antecedent_name = ?", consequent_name]) - errors[:base] << "Consequent tag must not be aliased to another tag" + errors.add(:base, "Consequent tag must not be aliased to another tag") end end def tag_categories_are_compatible if antecedent_tag.category != consequent_tag.category - errors[:base] << "Can't imply a #{antecedent_tag.category_name.downcase} tag to a #{consequent_tag.category_name.downcase} tag" + errors.add(:base, "Can't imply a #{antecedent_tag.category_name.downcase} tag to a #{consequent_tag.category_name.downcase} tag") end end @@ -114,24 +114,24 @@ class TagImplication < TagRelationship return if antecedent_tag.empty? || consequent_tag.empty? if antecedent_tag.post_count < MINIMUM_TAG_COUNT - errors[:base] << "'#{antecedent_name}' must have at least #{MINIMUM_TAG_COUNT} posts" + errors.add(:base, "'#{antecedent_name}' must have at least #{MINIMUM_TAG_COUNT} posts") elsif antecedent_tag.post_count < (MINIMUM_TAG_PERCENTAGE * consequent_tag.post_count) - errors[:base] << "'#{antecedent_name}' must have at least #{(MINIMUM_TAG_PERCENTAGE * consequent_tag.post_count).to_i} posts" + errors.add(:base, "'#{antecedent_name}' must have at least #{(MINIMUM_TAG_PERCENTAGE * consequent_tag.post_count).to_i} posts") end max_count = MAXIMUM_TAG_PERCENTAGE * PostQueryBuilder.new("~#{antecedent_name} ~#{consequent_name}").fast_count(timeout: 0).to_i if antecedent_tag.post_count > max_count && max_count > 0 - errors[:base] << "'#{antecedent_name}' can't make up than #{(MAXIMUM_TAG_PERCENTAGE * 100).to_i}% of '#{consequent_name}'" + errors.add(:base, "'#{antecedent_name}' can't make up than #{(MAXIMUM_TAG_PERCENTAGE * 100).to_i}% of '#{consequent_name}'") end end def has_wiki_page if !antecedent_tag.empty? && antecedent_wiki.blank? - errors[:base] << "'#{antecedent_name}' must have a wiki page" + errors.add(:base, "'#{antecedent_name}' must have a wiki page") end if !consequent_tag.empty? && consequent_wiki.blank? - errors[:base] << "'#{consequent_name}' must have a wiki page" + errors.add(:base, "'#{consequent_name}' must have a wiki page") end end end diff --git a/app/models/tag_relationship.rb b/app/models/tag_relationship.rb index d6b0f5c73..341f3182d 100644 --- a/app/models/tag_relationship.rb +++ b/app/models/tag_relationship.rb @@ -114,7 +114,7 @@ class TagRelationship < ApplicationRecord def antecedent_and_consequent_are_different if antecedent_name == consequent_name - errors[:base] << "Cannot alias or implicate a tag to itself" + errors.add(:base, "Cannot alias or implicate a tag to itself") end end diff --git a/app/models/upload.rb b/app/models/upload.rb index e3e8cda1a..04c61a078 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -12,13 +12,13 @@ class Upload < ApplicationRecord def validate_file_ext(record) if record.file_ext == "bin" - record.errors[:file_ext] << "is invalid (only JPEG, PNG, GIF, SWF, MP4, and WebM files are allowed" + record.errors.add(:file_ext, "is invalid (only JPEG, PNG, GIF, SWF, MP4, and WebM files are allowed") end end def validate_integrity(record) if record.media_file.is_corrupt? - record.errors[:file] << "is corrupted" + record.errors.add(:file, "is corrupted") end end @@ -37,24 +37,24 @@ class Upload < ApplicationRecord return end - record.errors[:md5] << "duplicate: #{md5_post.id}" + record.errors.add(:md5, "duplicate: #{md5_post.id}") end def validate_resolution(record) resolution = record.image_width.to_i * record.image_height.to_i if resolution > Danbooru.config.max_image_resolution - record.errors[:base] << "image resolution is too large (resolution: #{(resolution / 1_000_000.0).round(1)} megapixels (#{record.image_width}x#{record.image_height}); max: #{Danbooru.config.max_image_resolution / 1_000_000} megapixels)" + record.errors.add(:base, "image resolution is too large (resolution: #{(resolution / 1_000_000.0).round(1)} megapixels (#{record.image_width}x#{record.image_height}); max: #{Danbooru.config.max_image_resolution / 1_000_000} megapixels)") elsif record.image_width > Danbooru.config.max_image_width - record.errors[:image_width] << "is too large (width: #{record.image_width}; max width: #{Danbooru.config.max_image_width})" + record.errors.add(:image_width, "is too large (width: #{record.image_width}; max width: #{Danbooru.config.max_image_width})") elsif record.image_height > Danbooru.config.max_image_height - record.errors[:image_height] << "is too large (height: #{record.image_height}; max height: #{Danbooru.config.max_image_height})" + record.errors.add(:image_height, "is too large (height: #{record.image_height}; max height: #{Danbooru.config.max_image_height})") end end def validate_video_duration(record) if !record.uploader.is_admin? && record.media_file.is_video? && record.media_file.duration > 120 - record.errors[:base] << "video must not be longer than 2 minutes" + record.errors.add(:base, "video must not be longer than 2 minutes") end end end diff --git a/app/models/user_name_change_request.rb b/app/models/user_name_change_request.rb index 2cdafe5a6..c3261e000 100644 --- a/app/models/user_name_change_request.rb +++ b/app/models/user_name_change_request.rb @@ -30,7 +30,7 @@ class UserNameChangeRequest < ApplicationRecord def not_limited if UserNameChangeRequest.unscoped.where(user: user).where("created_at >= ?", 1.week.ago).exists? - errors[:base] << "You can only submit one name change request per week" + errors.add(:base, "You can only submit one name change request per week") end end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 6f914bd25..c9fd1a7c1 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -116,13 +116,13 @@ class WikiPage < ApplicationRecord tag_was = Tag.find_by_name(Tag.normalize_name(title_was)) if tag_was.present? && !tag_was.empty? - warnings[:base] << %!Warning: {{#{title_was}}} still has #{tag_was.post_count} #{"post".pluralize(tag_was.post_count)}. Be sure to move the posts! + warnings.add(:base, %!Warning: {{#{title_was}}} still has #{tag_was.post_count} #{"post".pluralize(tag_was.post_count)}. Be sure to move the posts!) end broken_wikis = WikiPage.linked_to(title_was) if broken_wikis.count > 0 broken_wiki_search = Rails.application.routes.url_helpers.wiki_pages_path(search: { linked_to: title_was }) - warnings[:base] << %!Warning: [[#{title_was}]] is still linked from "#{broken_wikis.count} #{"other wiki page".pluralize(broken_wikis.count)}":[#{broken_wiki_search}]. Update #{(broken_wikis.count > 1) ? "these wikis" : "this wiki"} to link to [[#{title}]] instead! + warnings.add(:base, %!Warning: [[#{title_was}]] is still linked from "#{broken_wikis.count} #{"other wiki page".pluralize(broken_wikis.count)}":[#{broken_wiki_search}]. Update #{(broken_wikis.count > 1) ? "these wikis" : "this wiki"} to link to [[#{title}]] instead!) end end From 35134abe8fe7a29051efcd692a1be27e8e9f13ab Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 04:02:25 -0600 Subject: [PATCH 014/132] post query builder: fix incompatibilities with Rails 6.1. * Rename the `#negate` and `#and` methods that we monkey patch into ActiveRecord::Relation. These methods are now defined in Rails 6.1, but they shadow our methods and have slightly different behavior. * Fix a call to `invert`. It no longer accepts an argument. --- app/logical/concerns/searchable.rb | 7 ++++--- app/logical/post_query_builder.rb | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index 151ad5f86..3cde3b5ec 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -10,12 +10,13 @@ module Searchable 1 + params.values.map { |v| parameter_hash?(v) ? parameter_depth(v) : 1 }.max end - def negate(kind = :nand) - unscoped.where(all.where_clause.invert(kind).ast) + def negate_relation + unscoped.where(all.where_clause.invert.ast) end # XXX hacky method to AND two relations together. - def and(relation) + # XXX Replace with ActiveRecord#and (cf https://github.com/rails/rails/pull/39328) + def and_relation(relation) q = all q = q.where(relation.where_clause.ast) if relation.where_clause.present? q = q.joins(relation.joins_values + q.joins_values) if relation.joins_values.present? diff --git a/app/logical/post_query_builder.rb b/app/logical/post_query_builder.rb index 19c67138e..4046485f1 100644 --- a/app/logical/post_query_builder.rb +++ b/app/logical/post_query_builder.rb @@ -92,8 +92,8 @@ class PostQueryBuilder def metatags_match(metatags, relation) metatags.each do |metatag| clause = metatag_matches(metatag.name, metatag.value, quoted: metatag.quoted) - clause = clause.negate if metatag.negated - relation = relation.and(clause) + clause = clause.negate_relation if metatag.negated + relation = relation.and_relation(clause) end relation From b3ad13e6e3f61c1f3fd70f72035ebbb754dfa279 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 14:55:49 -0600 Subject: [PATCH 015/132] users: add new owner level. Add a new Owner user level for the site owner. Highly sensitive operations like manually changing the passwords of other users will be restricted to the site owner. --- app/javascript/src/styles/common/user_styles.scss | 4 ++++ .../src/styles/specific/user_tooltips.scss | 1 + app/logical/user_deletion.rb | 2 +- app/models/user.rb | 15 ++++++++++++++- test/factories/user.rb | 5 +++++ test/mailers/previews/user_mailer_preview.rb | 2 +- test/unit/user_test.rb | 7 +++++++ 7 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/javascript/src/styles/common/user_styles.scss b/app/javascript/src/styles/common/user_styles.scss index 52cc6453f..14aff9a51 100644 --- a/app/javascript/src/styles/common/user_styles.scss +++ b/app/javascript/src/styles/common/user_styles.scss @@ -1,4 +1,8 @@ body[data-current-user-style-usernames="true"] { + a.user-owner { + color: var(--user-admin-color); + } + a.user-admin { color: var(--user-admin-color); } diff --git a/app/javascript/src/styles/specific/user_tooltips.scss b/app/javascript/src/styles/specific/user_tooltips.scss index b7133ef31..881dae1d6 100644 --- a/app/javascript/src/styles/specific/user_tooltips.scss +++ b/app/javascript/src/styles/specific/user_tooltips.scss @@ -27,6 +27,7 @@ margin-right: 0.25em; border-radius: 3px; + &.user-tooltip-badge-owner { background-color: var(--user-admin-color); } &.user-tooltip-badge-admin { background-color: var(--user-admin-color); } &.user-tooltip-badge-moderator { background-color: var(--user-moderator-color); } &.user-tooltip-badge-approver { background-color: var(--user-builder-color); } diff --git a/app/logical/user_deletion.rb b/app/logical/user_deletion.rb index 0ae9cc3fa..e39192736 100644 --- a/app/logical/user_deletion.rb +++ b/app/logical/user_deletion.rb @@ -64,7 +64,7 @@ class UserDeletion errors.add(:base, "Password is incorrect") end - if user.level >= User::Levels::ADMIN + if user.is_admin? errors.add(:base, "Admins cannot delete their account") end end diff --git a/app/models/user.rb b/app/models/user.rb index 5ec821bcb..73e2cb116 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,6 +12,7 @@ class User < ApplicationRecord BUILDER = 32 MODERATOR = 40 ADMIN = 50 + OWNER = 60 end # Used for `before_action :_only`. Must have a corresponding `is_?` method. @@ -191,6 +192,10 @@ class User < ApplicationRecord extend ActiveSupport::Concern module ClassMethods + def owner + User.find_by!(level: Levels::ADMIN) + end + def system User.find_by!(name: Danbooru.config.system_user) end @@ -208,7 +213,8 @@ class User < ApplicationRecord "Platinum" => Levels::PLATINUM, "Builder" => Levels::BUILDER, "Moderator" => Levels::MODERATOR, - "Admin" => Levels::ADMIN + "Admin" => Levels::ADMIN, + "Owner" => Levels::OWNER } end @@ -235,6 +241,9 @@ class User < ApplicationRecord when Levels::ADMIN "Admin" + when Levels::OWNER + "Owner" + else "" end @@ -299,6 +308,10 @@ class User < ApplicationRecord level >= Levels::ADMIN end + def is_owner? + level >= Levels::OWNER + end + def is_approver? can_approve_posts? end diff --git a/test/factories/user.rb b/test/factories/user.rb index 577334f40..e06e2c29c 100644 --- a/test/factories/user.rb +++ b/test/factories/user.rb @@ -56,6 +56,11 @@ FactoryBot.define do can_approve_posts {true} end + factory(:owner_user) do + level { User::Levels::OWNER } + can_approve_posts {true} + end + factory(:uploader) do created_at { 2.weeks.ago } end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb index 3c466e034..314267a9c 100644 --- a/test/mailers/previews/user_mailer_preview.rb +++ b/test/mailers/previews/user_mailer_preview.rb @@ -1,6 +1,6 @@ class UserMailerPreview < ActionMailer::Preview def dmail_notice - dmail = User.admins.first.dmails.first + dmail = User.system.dmails.first UserMailer.dmail_notice(dmail) end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 36d34af45..012d1779b 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -45,7 +45,14 @@ class UserTest < ActiveSupport::TestCase end should "normalize its level" do + user = FactoryBot.create(:user, :level => User::Levels::OWNER) + assert(user.is_owner?) + assert(user.is_admin?) + assert(user.is_moderator?) + assert(user.is_gold?) + user = FactoryBot.create(:user, :level => User::Levels::ADMIN) + assert(!user.is_owner?) assert(user.is_moderator?) assert(user.is_gold?) From c82e05d8280d1fc590dbcaf57d62a37499fcbef6 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 16:54:45 -0600 Subject: [PATCH 016/132] users: add stricter checks for user promotions. New rules for user promotions: * Moderators can no longer promote other users to moderator level. Only Admins can promote users to Mod level. Mods can only promote up to Builder level. * Admins can no longer promote other users to Admin level. Only Owners can promote users to Admin. Admins can only promote up to Mod level. * Admins can no longer demote themselves or other admins. These rules are being changed to account for the new Owner user level. Also change it so that when a user upgrades their account, the promotion is done by DanbooruBot. This means that the inviter and the mod action will show DanbooruBot as the promoter instead of the user themselves. --- app/controllers/admin/users_controller.rb | 8 +- app/controllers/user_upgrades_controller.rb | 2 +- app/logical/user_promotion.rb | 50 +++++------ app/models/user.rb | 4 +- db/populate.rb | 2 +- .../functional/admin/users_controller_test.rb | 18 ++++ test/unit/user_test.rb | 85 +++++++++++++++++-- 7 files changed, 131 insertions(+), 38 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1422bd3de..0337c892a 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -6,7 +6,13 @@ module Admin def update @user = authorize User.find(params[:id]), :promote? - @user.promote_to!(params[:user][:level], params[:user]) + + @level = params.dig(:user, :level) + @can_upload_free = params.dig(:user, :can_upload_free) + @can_approve_posts = params.dig(:user, :can_approve_posts) + + @user.promote_to!(@level, CurrentUser.user, can_upload_free: @can_upload_free, can_approve_posts: @can_approve_posts) + redirect_to edit_admin_user_path(@user), :notice => "User updated" end end diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 8001b1d88..903f7b6ec 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -48,7 +48,7 @@ class UserUpgradesController < ApplicationController :card => params[:stripeToken], :description => params[:desc] ) - @user.promote_to!(level, is_upgrade: true) + @user.promote_to!(level, User.system, is_upgrade: true) flash[:success] = true rescue Stripe::CardError => e DanbooruLogger.log(e) diff --git a/app/logical/user_promotion.rb b/app/logical/user_promotion.rb index b3b7a32bb..457c67cce 100644 --- a/app/logical/user_promotion.rb +++ b/app/logical/user_promotion.rb @@ -1,33 +1,28 @@ class UserPromotion - attr_reader :user, :promoter, :new_level, :options, :old_can_approve_posts, :old_can_upload_free + attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts, :is_upgrade - def initialize(user, promoter, new_level, options = {}) + def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil, is_upgrade: false) @user = user @promoter = promoter - @new_level = new_level - @options = options + @new_level = new_level.to_i + @can_upload_free = can_upload_free + @can_approve_posts = can_approve_posts + @is_upgrade = is_upgrade end def promote! - validate + validate! @old_can_approve_posts = user.can_approve_posts? @old_can_upload_free = user.can_upload_free? user.level = new_level + user.can_upload_free = can_upload_free unless can_upload_free.nil? + user.can_approve_posts = can_approve_posts unless can_approve_posts.nil? + user.inviter = promoter - if options.key?(:can_approve_posts) - user.can_approve_posts = options[:can_approve_posts] - end - - if options.key?(:can_upload_free) - user.can_upload_free = options[:can_upload_free] - end - - user.inviter_id = promoter.id - - create_user_feedback unless options[:is_upgrade] - create_dmail unless options[:skip_dmail] + create_user_feedback unless is_upgrade + create_dmail create_mod_actions user.save @@ -45,20 +40,21 @@ class UserPromotion end if user.level_changed? - category = options[:is_upgrade] ? :user_account_upgrade : :user_level_change + category = is_upgrade ? :user_account_upgrade : :user_level_change ModAction.log(%{"#{user.name}":/users/#{user.id} level changed #{user.level_string_was} -> #{user.level_string}}, category) end end - def validate - # admins can do anything - return if promoter.is_admin? - - # can't promote/demote moderators - raise User::PrivilegeError if user.is_moderator? - - # can't promote to admin - raise User::PrivilegeError if new_level.to_i >= User::Levels::ADMIN + def validate! + if !promoter.is_moderator? + raise User::PrivilegeError, "You can't promote or demote other users" + elsif promoter == user + raise User::PrivilegeError, "You can't promote or demote yourself" + elsif new_level >= promoter.level + raise User::PrivilegeError, "You can't promote other users to your rank or above" + elsif user.level >= promoter.level + raise User::PrivilegeError, "You can't promote or demote other users at your rank or above" + end end def build_messages diff --git a/app/models/user.rb b/app/models/user.rb index 73e2cb116..ec14ed950 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -250,8 +250,8 @@ class User < ApplicationRecord end end - def promote_to!(new_level, options = {}) - UserPromotion.new(self, CurrentUser.user, new_level, options).promote! + def promote_to!(new_level, promoter = CurrentUser.user, **options) + UserPromotion.new(self, promoter, new_level, **options).promote! end def promote_to_admin_if_first_user diff --git a/db/populate.rb b/db/populate.rb index c6451e7b7..7a66a7080 100644 --- a/db/populate.rb +++ b/db/populate.rb @@ -53,7 +53,7 @@ if User.count == 0 password: "password1", password_confirmation: "password1" ) - newuser.promote_to!(User::Levels.const_get(level), :is_upgrade => true, :skip_dmail => true) + newuser.promote_to!(User::Levels.const_get(level), user) end newuser = User.create( diff --git a/test/functional/admin/users_controller_test.rb b/test/functional/admin/users_controller_test.rb index 2cc47a0fe..c56470544 100644 --- a/test/functional/admin/users_controller_test.rb +++ b/test/functional/admin/users_controller_test.rb @@ -24,6 +24,24 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(@mod.id, @user.inviter_id) end + should "promote the user to unrestricted uploads" do + put_auth admin_user_path(@user), @mod, params: { user: { level: User::Levels::BUILDER, can_upload_free: true }} + + assert_redirected_to(edit_admin_user_path(@user.reload)) + assert_equal(true, @user.is_builder?) + assert_equal(true, @user.can_upload_free?) + assert_equal(false, @user.can_approve_posts?) + end + + should "promote the user to approver" do + put_auth admin_user_path(@user), @mod, params: { user: { level: User::Levels::BUILDER, can_approve_posts: true }} + + assert_redirected_to(edit_admin_user_path(@user.reload)) + assert_equal(true, @user.is_builder?) + assert_equal(false, @user.can_upload_free?) + assert_equal(true, @user.can_approve_posts?) + end + context "promoted to an admin" do should "fail" do put_auth admin_user_path(@user), @mod, params: {:user => {:level => "50"}} diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 012d1779b..cfa83b76a 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -1,6 +1,19 @@ require 'test_helper' class UserTest < ActiveSupport::TestCase + def assert_promoted_to(new_level, user, promoter) + user.promote_to!(new_level, promoter) + assert_equal(new_level, user.reload.level) + end + + def assert_not_promoted_to(new_level, user, promoter) + assert_raise(User::PrivilegeError) do + user.promote_to!(new_level, promoter) + end + + assert_not_equal(new_level, user.reload.level) + end + context "A user" do setup do @user = FactoryBot.create(:user) @@ -15,14 +28,74 @@ class UserTest < ActiveSupport::TestCase context "promoting a user" do setup do - CurrentUser.user = FactoryBot.create(:moderator_user) + @builder = create(:builder_user) + @mod = create(:moderator_user) + @admin = create(:admin_user) + @owner = create(:owner_user) + end + + should "allow moderators to promote users up to builder level" do + assert_promoted_to(User::Levels::GOLD, @user, @mod) + assert_promoted_to(User::Levels::PLATINUM, @user, @mod) + assert_promoted_to(User::Levels::BUILDER, @user, @mod) + + assert_not_promoted_to(User::Levels::MODERATOR, @user, @mod) + assert_not_promoted_to(User::Levels::ADMIN, @user, @mod) + assert_not_promoted_to(User::Levels::OWNER, @user, @mod) + end + + should "allow admins to promote users up to moderator level" do + assert_promoted_to(User::Levels::GOLD, @user, @admin) + assert_promoted_to(User::Levels::PLATINUM, @user, @admin) + assert_promoted_to(User::Levels::BUILDER, @user, @admin) + assert_promoted_to(User::Levels::MODERATOR, @user, @admin) + + assert_not_promoted_to(User::Levels::ADMIN, @user, @admin) + assert_not_promoted_to(User::Levels::OWNER, @user, @admin) + end + + should "allow the owner to promote users up to admin level" do + assert_promoted_to(User::Levels::GOLD, @user, @owner) + assert_promoted_to(User::Levels::PLATINUM, @user, @owner) + assert_promoted_to(User::Levels::BUILDER, @user, @owner) + assert_promoted_to(User::Levels::MODERATOR, @user, @owner) + assert_promoted_to(User::Levels::ADMIN, @user, @owner) + + assert_not_promoted_to(User::Levels::OWNER, @user, @owner) + end + + should "not allow non-moderators to promote other users" do + assert_not_promoted_to(User::Levels::GOLD, @user, @builder) + assert_not_promoted_to(User::Levels::PLATINUM, @user, @builder) + assert_not_promoted_to(User::Levels::BUILDER, @user, @builder) + assert_not_promoted_to(User::Levels::MODERATOR, @user, @builder) + assert_not_promoted_to(User::Levels::ADMIN, @user, @builder) + assert_not_promoted_to(User::Levels::OWNER, @user, @builder) + end + + should "not allow users to promote or demote other users at their rank or above" do + assert_not_promoted_to(User::Levels::ADMIN, create(:moderator_user), @mod) + assert_not_promoted_to(User::Levels::BUILDER, create(:moderator_user), @mod) + + assert_not_promoted_to(User::Levels::OWNER, create(:admin_user), @admin) + assert_not_promoted_to(User::Levels::MODERATOR, create(:admin_user), @admin) + + assert_not_promoted_to(User::Levels::ADMIN, create(:owner_user), @owner) + end + + should "not allow users to promote themselves" do + assert_not_promoted_to(User::Levels::ADMIN, @mod, @mod) + assert_not_promoted_to(User::Levels::OWNER, @admin, @admin) + end + + should "not allow users to demote themselves" do + assert_not_promoted_to(User::Levels::MEMBER, @mod, @mod) + assert_not_promoted_to(User::Levels::MEMBER, @admin, @admin) + assert_not_promoted_to(User::Levels::MEMBER, @owner, @owner) end should "create a neutral feedback" do - assert_difference("UserFeedback.count") do - @user.promote_to!(User::Levels::GOLD) - end - + @user.promote_to!(User::Levels::GOLD, @mod) assert_equal("You have been promoted to a Gold level account from Member.", @user.feedback.last.body) end @@ -31,7 +104,7 @@ class UserTest < ActiveSupport::TestCase User.stubs(:system).returns(bot) assert_difference("Dmail.count", 1) do - @user.promote_to!(User::Levels::GOLD) + @user.promote_to!(User::Levels::GOLD, @admin) end assert(@user.dmails.exists?(from: bot, to: @user, title: "Your account has been updated")) From 2144f45fa462390bcde336ecc908d8a92149430c Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 18:33:46 -0600 Subject: [PATCH 017/132] users: add account upgrade integration tests. * Test that the user upgrade process integrates with Stripe correctly. * Replace a deprecated `card` param with `source` in `Stripe::Charge.create`. * Rescue Stripe::StripeError instead of Stripe::CardError so that we handle failures outside of card failures, such as network errors. --- Gemfile | 1 + Gemfile.lock | 6 ++ app/controllers/user_upgrades_controller.rb | 9 +- .../user_upgrades_controller_test.rb | 100 ++++++++++++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index 1aef4acc1..92332c2e6 100644 --- a/Gemfile +++ b/Gemfile @@ -86,4 +86,5 @@ group :test do gem "capybara" gem "selenium-webdriver" gem "codecov", require: false + gem 'stripe-ruby-mock', require: "stripe_mock" end diff --git a/Gemfile.lock b/Gemfile.lock index e93fe7071..1bab4040e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -134,6 +134,7 @@ GEM concurrent-ruby (1.1.7) crass (1.0.6) daemons (1.3.1) + dante (0.2.0) delayed_job (4.1.8) activesupport (>= 3.0, < 6.1) delayed_job_active_record (4.1.4) @@ -354,6 +355,10 @@ GEM streamio-ffmpeg (3.0.2) multi_json (~> 1.8) stripe (5.28.0) + stripe-ruby-mock (3.0.1) + dante (>= 0.2.0) + multi_json (~> 1.0) + stripe (> 5, < 6) thor (1.0.1) thread_safe (0.3.6) tzinfo (1.2.8) @@ -452,6 +457,7 @@ DEPENDENCIES stackprof streamio-ffmpeg stripe + stripe-ruby-mock unicorn unicorn-worker-killer webpacker (>= 4.0.x) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 903f7b6ec..be7ee116a 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -42,15 +42,10 @@ class UserUpgradesController < ApplicationController end begin - charge = Stripe::Charge.create( - :amount => cost, - :currency => "usd", - :card => params[:stripeToken], - :description => params[:desc] - ) + charge = Stripe::Charge.create(amount: cost, currency: "usd", source: params[:stripeToken], description: params[:desc]) @user.promote_to!(level, User.system, is_upgrade: true) flash[:success] = true - rescue Stripe::CardError => e + rescue Stripe::StripeError => e DanbooruLogger.log(e) flash[:error] = e.message end diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index b4450f8a1..8fbd64d34 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -1,6 +1,14 @@ require 'test_helper' class UserUpgradesControllerTest < ActionDispatch::IntegrationTest + setup do + StripeMock.start + end + + teardown do + StripeMock.stop + end + context "The user upgrades controller" do context "new action" do should "render" do @@ -15,5 +23,97 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + context "create action" do + setup do + @user = create(:user) + @token = StripeMock.generate_card_token + end + + context "a self upgrade" do + should "upgrade a Member to Gold" do + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_gold?) + end + + should "upgrade a Member to Platinum" do + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Platinum" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_platinum?) + end + + should "upgrade a Gold user to Platinum" do + @user.update!(level: User::Levels::GOLD) + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade Gold to Platinum" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_platinum?) + end + + should "log an account upgrade modaction" do + assert_difference("ModAction.user_account_upgrade.count") do + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } + end + end + + should "send the user a dmail" do + assert_difference("@user.dmails.received.count") do + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } + end + end + end + + context "a gifted upgrade" do + should "upgrade the user to Gold" do + @other_user = create(:user) + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold", user_id: @other_user.id } + + assert_redirected_to user_upgrade_path(user_id: @other_user.id) + assert_equal(true, @other_user.reload.is_gold?) + assert_equal(false, @user.reload.is_gold?) + end + end + + context "an upgrade with a missing Stripe token" do + should "not upgrade the user" do + post_auth user_upgrade_path, @user, params: { desc: "Upgrade to Gold" } + + assert_response :success + assert_equal(true, @user.reload.is_member?) + end + end + + context "an upgrade with an invalid Stripe token" do + should "not upgrade the user" do + post_auth user_upgrade_path, @user, params: { stripeToken: "garbage", desc: "Upgrade to Gold" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_member?) + end + end + + context "an upgrade with an credit card that is declined" do + should "not upgrade the user" do + StripeMock.prepare_card_error(:card_declined) + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_member?) + end + end + + context "an upgrade with an credit card that is expired" do + should "not upgrade the user" do + StripeMock.prepare_card_error(:expired_card) + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } + + assert_redirected_to user_upgrade_path + assert_equal(true, @user.reload.is_member?) + end + end + end end end From d8b51e3f022b28bf1bcc594fcd1c5f1aca79724d Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 18:43:34 -0600 Subject: [PATCH 018/132] users: don't allow gifting upgrades to demote privileged users. Don't allow gifting Gold or Platinum upgrades to users above Platinum level. Fixes an exploit where you could demote Builders and above by gifting them an upgrade. --- app/logical/user_promotion.rb | 2 ++ test/functional/user_upgrades_controller_test.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/app/logical/user_promotion.rb b/app/logical/user_promotion.rb index 457c67cce..e9653bb2d 100644 --- a/app/logical/user_promotion.rb +++ b/app/logical/user_promotion.rb @@ -54,6 +54,8 @@ class UserPromotion raise User::PrivilegeError, "You can't promote other users to your rank or above" elsif user.level >= promoter.level raise User::PrivilegeError, "You can't promote or demote other users at your rank or above" + elsif is_upgrade && user.is_builder? + raise User::PrivilegeError, "You can't upgrade a user that is above Platinum level" end end diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index 8fbd64d34..db88aa327 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -77,6 +77,16 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end end + context "an upgrade for a user above Platinum level" do + should "not demote the user" do + @builder = create(:builder_user) + post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold", user_id: @builder.id } + + assert_response 403 + assert_equal(true, @builder.reload.is_builder?) + end + end + context "an upgrade with a missing Stripe token" do should "not upgrade the user" do post_auth user_upgrade_path, @user, params: { desc: "Upgrade to Gold" } From 86bba56eda6b1757e67e67778130f1e5b5d3edda Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 19:07:19 -0600 Subject: [PATCH 019/132] users: allow site owner to reset passwords of other users. --- app/controllers/passwords_controller.rb | 2 +- app/policies/password_policy.rb | 2 +- test/functional/passwords_controller_test.rb | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 0e78f9c2e..f5fcc40e0 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -9,7 +9,7 @@ class PasswordsController < ApplicationController def update @user = authorize User.find(params[:user_id]), policy_class: PasswordPolicy - if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id]) + if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id]) || CurrentUser.user.is_owner? @user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation]) else @user.errors.add(:base, "Incorrect password") diff --git a/app/policies/password_policy.rb b/app/policies/password_policy.rb index a315c507e..3c34af574 100644 --- a/app/policies/password_policy.rb +++ b/app/policies/password_policy.rb @@ -1,5 +1,5 @@ class PasswordPolicy < ApplicationPolicy def update? - record.id == user.id || user.is_admin? + record.id == user.id || user.is_owner? end end diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb index aae7c9acb..f865957f2 100644 --- a/test/functional/passwords_controller_test.rb +++ b/test/functional/passwords_controller_test.rb @@ -31,6 +31,24 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest assert_equal(@user, @user.authenticate_password("abcde")) end + should "allow the site owner to change the password of other users" do + @owner = create(:owner_user) + put_auth user_password_path(@user), @owner, params: { user: { password: "abcde", password_confirmation: "abcde" } } + + assert_redirected_to @user + assert_equal(false, @user.reload.authenticate_password("12345")) + assert_equal(@user, @user.authenticate_password("abcde")) + end + + should "not allow non-owners to change the password of other users" do + @admin = create(:admin_user) + put_auth user_password_path(@user), @admin, params: { user: { old_password: "12345", password: "abcde", password_confirmation: "abcde" } } + + assert_response 403 + assert_equal(@user, @user.reload.authenticate_password("12345")) + assert_equal(false, @user.authenticate_password("abcde")) + end + should "not update the password when given an invalid old password" do put_auth user_password_path(@user), @user, params: { user: { old_password: "3qoirjqe", password: "abcde", password_confirmation: "abcde" } } From 9f09c495e46b62a780d61daacd775087ac0c83f1 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 19:09:49 -0600 Subject: [PATCH 020/132] users: don't allow admins to edit user levels directly. Don't allow admins to bypass promotion restrictions by manually updating user levels with a `PUT /users/:id` API call. Level changes have to go through the /admin/users/:id/edit page. --- app/policies/user_policy.rb | 1 - test/functional/users_controller_test.rb | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 0c9c0469c..6ca305635 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -47,7 +47,6 @@ class UserPolicy < ApplicationPolicy :disable_tagged_filenames, :disable_cropped_thumbnails, :disable_mobile_gestures, :enable_safe_mode, :enable_desktop_mode, :disable_post_tooltips, - (:level if CurrentUser.is_admin?) ].compact end diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index c6c02d522..7d35e591f 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -335,11 +335,11 @@ class UsersControllerTest < ActionDispatch::IntegrationTest context "changing the level" do should "not work" do - @cuser = create(:user) - put_auth user_path(@user), @cuser, params: {:user => {:level => 40}} + @owner = create(:owner_user) + put_auth user_path(@user), @owner, params: { user: { level: User::Levels::BUILDER }} assert_response 403 - assert_equal(20, @user.reload.level) + assert_equal(User::Levels::MEMBER, @user.reload.level) end end From 67eefadd7f6528b10c4f973bee7d68cd183537df Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 19:50:25 -0600 Subject: [PATCH 021/132] users: let mods see email addresses on user profiles. * Let Mods and Admins see the email addresses of users below their level. * Let users see their own email address on their profile. * Let users verify or edit their email address from their profile. This is to make catching sockpuppets easier, and to make it easier for users to fix their email. --- app/javascript/src/styles/base/040_colors.css | 6 ++++ app/javascript/src/styles/specific/users.scss | 8 +++++ app/policies/email_address_policy.rb | 2 +- app/policies/nil_class_policy.rb | 21 ++++++++++++++ app/views/emails/verify.html.erb | 8 ++--- app/views/users/_statistics.html.erb | 28 ++++++++++++++++++ test/functional/users_controller_test.rb | 29 ++++++++++++++++++- 7 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 app/policies/nil_class_policy.rb diff --git a/app/javascript/src/styles/base/040_colors.css b/app/javascript/src/styles/base/040_colors.css index 28eef86ec..5dc30e914 100644 --- a/app/javascript/src/styles/base/040_colors.css +++ b/app/javascript/src/styles/base/040_colors.css @@ -201,6 +201,9 @@ --user-member-color: var(--link-color); --user-banned-color: black; + --user-verified-email-color: #0A0; + --user-unverified-email-color: #F80; + --news-updates-background: #EEE; --news-updates-border: 2px solid #666; @@ -291,6 +294,9 @@ body[data-current-user-theme="dark"] { --user-moderator-color: var(--green-1); --user-admin-color: var(--red-1); + --user-verified-email-color: var(--green-1); + --user-unverified-email-color: var(--yellow-1); + /* misc specific colors */ --autocomplete-selected-background-color: var(--grey-3); --autocomplete-border: 1px solid var(--grey-4); diff --git a/app/javascript/src/styles/specific/users.scss b/app/javascript/src/styles/specific/users.scss index 708538025..ba9af319f 100644 --- a/app/javascript/src/styles/specific/users.scss +++ b/app/javascript/src/styles/specific/users.scss @@ -30,6 +30,14 @@ div#c-users { p { margin-bottom: 0.5em; } + + .user-verified-email-icon { + color: var(--user-verified-email-color); + } + + .user-unverified-email-icon { + color: var(--user-unverified-email-color); + } } } diff --git a/app/policies/email_address_policy.rb b/app/policies/email_address_policy.rb index cd92232d1..152469125 100644 --- a/app/policies/email_address_policy.rb +++ b/app/policies/email_address_policy.rb @@ -1,6 +1,6 @@ class EmailAddressPolicy < ApplicationPolicy def show? - record.user_id == user.id + record.user_id == user.id || (user.is_moderator? && record.user.level < user.level) end def update? diff --git a/app/policies/nil_class_policy.rb b/app/policies/nil_class_policy.rb new file mode 100644 index 000000000..8bce8769e --- /dev/null +++ b/app/policies/nil_class_policy.rb @@ -0,0 +1,21 @@ +class NilClassPolicy < ApplicationPolicy + def index? + false + end + + def show? + false + end + + def create? + false + end + + def update? + false + end + + def destroy? + false + end +end diff --git a/app/views/emails/verify.html.erb b/app/views/emails/verify.html.erb index 5790b4f57..f152a3558 100644 --- a/app/views/emails/verify.html.erb +++ b/app/views/emails/verify.html.erb @@ -6,12 +6,12 @@ <% if @user.is_restricted? %>

Your account is restricted because you signed up from a VPN or proxy. - You can still use the site, but you won't be able to leave comments, edit - tags, or upload posts until you verify your account.

+ You can still use the site, but you must verify your email address to be + able to leave comments, edit tags, or upload posts.

<% end %> -

Click below to send an email to <%= @email_address.address %> - to verify your account.

+

Your email address is unverified. Click below to send an email to + <%= @email_address.address %> to verify your email address.

<%= edit_form_for(@user, method: :post, url: send_confirmation_user_email_path(@user)) do |f| %> <%= f.submit "Send confirmation email" %> diff --git a/app/views/users/_statistics.html.erb b/app/views/users/_statistics.html.erb index 884a4c100..2a8ee6ec9 100644 --- a/app/views/users/_statistics.html.erb +++ b/app/views/users/_statistics.html.erb @@ -10,6 +10,7 @@ Join Date <%= presenter.join_date %> + <% if policy(IpAddress).show? %> Last IP @@ -27,6 +28,33 @@ <% end %> + <% if policy(user.email_address).show? %> + + Email Address + + <% if user.email_address.present? %> + <%= user.email_address.address %> + + <% if user == CurrentUser.user %> + (<%= link_to "edit", edit_user_email_path(user) %>) + <% end %> + + <% if user.email_address.is_verified? %> + + <% elsif user == CurrentUser.user %> + <%= link_to verify_user_email_path(user) do %> + + <% end %> + <% else %> + + <% end %> + <% else %> + none + <% end %> + + + <% end %> + Inviter <% if user.inviter %> diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 7d35e591f..5c2e2b583 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -114,7 +114,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest context "show action" do setup do # flesh out profile to get more test coverage of user presenter. - @user = create(:banned_user, can_approve_posts: true, created_at: 2.weeks.ago) + @user = create(:user, can_approve_posts: true, created_at: 2.weeks.ago) as(@user) do create(:saved_search, user: @user) create(:post, uploader: @user, tag_string: "fav:#{@user.name}") @@ -152,6 +152,33 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(false, xml["user"]["enable_safe_mode"]) end + context "for a user with an email address" do + setup do + create(:email_address, user: @user) + end + + should "show the email address to the user themselves" do + get_auth user_path(@user), @user + + assert_response :success + assert_select ".user-email-address", count: 1 + end + + should "show the email address to mods" do + get_auth user_path(@user), create(:moderator_user) + + assert_response :success + assert_select ".user-email-address", count: 1 + end + + should "not show the email address to other users" do + get_auth user_path(@user), create(:user) + + assert_response :success + assert_select ".user-email-address", count: 0 + end + end + context "for a tooltip" do setup do @banned = create(:banned_user) From 2e633f84f6774132a440b034bb3fbb82f6a11e66 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 21:02:12 -0600 Subject: [PATCH 022/132] emails: add /emails index page. Add emails index page at https://danbooru.donmai.us/emails. Mods can use this page to view and search emails belonging to users below mod level. --- app/controllers/emails_controller.rb | 13 ++++++++- app/models/email_address.rb | 17 ++++++++++++ app/policies/email_address_policy.rb | 4 +++ app/views/emails/index.html.erb | 32 +++++++++++++++++++++++ app/views/static/site_map.html.erb | 4 +++ config/routes.rb | 1 + test/functional/emails_controller_test.rb | 20 ++++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/views/emails/index.html.erb diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb index 37b385e48..2eb034ecf 100644 --- a/app/controllers/emails_controller.rb +++ b/app/controllers/emails_controller.rb @@ -1,8 +1,19 @@ class EmailsController < ApplicationController respond_to :html, :xml, :json + def index + @email_addresses = authorize EmailAddress.visible(CurrentUser.user).paginated_search(params, count_pages: true) + @email_addresses = @email_addresses.includes(:user) + respond_with(@email_addresses) + end + def show - @email_address = authorize EmailAddress.find_by_user_id!(params[:user_id]) + if params[:user_id] + @email_address = authorize EmailAddress.find_by_user_id!(params[:user_id]) + else + @email_address = authorize EmailAddress.find(params[:id]) + end + respond_with(@email_address) end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 367af7a97..9d4dd6f22 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -10,6 +10,14 @@ class EmailAddress < ApplicationRecord validate :validate_deliverable, on: :deliverable after_save :update_user + def self.visible(user) + if user.is_moderator? + where(user: User.where("level < ?", user.level)).or(where(user: user)) + else + none + end + end + def address=(value) self.normalized_address = EmailValidator.normalize(value) || address super @@ -29,6 +37,15 @@ class EmailAddress < ApplicationRecord user.update!(is_verified: is_verified? && nondisposable?) end + def self.search(params) + q = super + + q = q.search_attributes(params, :user, :address, :normalized_address, :is_verified, :is_deliverable) + q = q.apply_default_order(params) + + q + end + concerning :VerificationMethods do def verifier @verifier ||= Danbooru::MessageVerifier.new(:email_verification_key) diff --git a/app/policies/email_address_policy.rb b/app/policies/email_address_policy.rb index 152469125..f9ddcdfdb 100644 --- a/app/policies/email_address_policy.rb +++ b/app/policies/email_address_policy.rb @@ -1,4 +1,8 @@ class EmailAddressPolicy < ApplicationPolicy + def index? + user.is_moderator? + end + def show? record.user_id == user.id || (user.is_moderator? && record.user.level < user.level) end diff --git a/app/views/emails/index.html.erb b/app/views/emails/index.html.erb new file mode 100644 index 000000000..c1f7429ac --- /dev/null +++ b/app/views/emails/index.html.erb @@ -0,0 +1,32 @@ +
+
+ <%= search_form_for(emails_path) do |f| %> + <%= f.simple_fields_for :user do |fa| %> + <%= fa.input :name, label: "User Name", input_html: { value: params.dig(:search, :user, :name), "data-autocomplete": "user" } %> + <% end %> + + <%= f.input :address_ilike, label: "Address", input_html: { value: params[:search][:address] }, hint: "Use * for wildcard" %> + <%= f.input :is_verified, label: "Verified?", collection: %w[Yes No], selected: params[:search][:is_verified] %> + <%= f.submit "Search" %> + <% end %> + + <%= table_for @email_addresses, class: "striped autofit" do |t| %> + <% t.column :user do |email| %> + <%= link_to_user email.user %> + <% end %> + <% t.column :address %> + <% t.column :is_verified, name: "Verified?" do |email| %> + <% if email.is_verified? %> + <%= link_to "Yes", emails_path(search: { is_verified: true }) %> + <% else %> + <%= link_to "No", emails_path(search: { is_verified: false }) %> + <% end %> + <% end %> + <% t.column :updated_at, name: "Updated" do |email| %> + <%= time_ago_in_words_tagged(email.updated_at) %> + <% end %> + <% end %> + + <%= numbered_paginator(@email_addresses) %> +
+
diff --git a/app/views/static/site_map.html.erb b/app/views/static/site_map.html.erb index b88b8c138..4e82675d2 100644 --- a/app/views/static/site_map.html.erb +++ b/app/views/static/site_map.html.erb @@ -154,6 +154,10 @@
  • <%= link_to("Moderation Reports", moderation_reports_path) %>
  • <% end %> + <% if policy(EmailAddress).index? %> +
  • <%= link_to("Email Addresses", emails_path) %>
  • + <% end %> + <% if policy(IpAddress).index? %>
  • <%= link_to("IP Addresses", ip_addresses_path) %>
  • <% end %> diff --git a/config/routes.rb b/config/routes.rb index ce3a273c1..11d7e7d28 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,7 @@ Rails.application.routes.draw do end resource :dtext_preview, :only => [:create] resources :dtext_links, only: [:index] + resources :emails, only: [:index, :show] resources :favorites, :only => [:index, :create, :destroy] resources :favorite_groups do member do diff --git a/test/functional/emails_controller_test.rb b/test/functional/emails_controller_test.rb index ec08a2c03..4da9bb9a5 100644 --- a/test/functional/emails_controller_test.rb +++ b/test/functional/emails_controller_test.rb @@ -10,6 +10,26 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest @restricted_user = create(:user, requires_verification: true, is_verified: false) end + context "#index" do + should "not let regular users see emails belonging to other users" do + get_auth emails_path, @user + assert_response 403 + end + + should "let mods see emails belonging to themselves and all users below mod level" do + @mod1 = create(:moderator_user, email_address: build(:email_address)) + @mod2 = create(:moderator_user, email_address: build(:email_address)) + + get_auth emails_path, @mod1 + + assert_response :success + assert_select "#email-address-#{@user.email_address.id}", count: 1 + assert_select "#email-address-#{@other_user.email_address.id}", count: 1 + assert_select "#email-address-#{@mod1.email_address.id}", count: 1 + assert_select "#email-address-#{@mod2.email_address.id}", count: 0 + end + end + context "#show" do should "render" do get_auth user_email_path(@user), @user, as: :json From eae3c1942d9f3fb011689d24e3aea3c2450942e6 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 21:35:18 -0600 Subject: [PATCH 023/132] dmails: allow site owner to read all mails. Allow site owner to read dmails sent to other users. This is make it easier to investigate spam without having to drop into the dev console. --- app/policies/dmail_policy.rb | 1 + test/functional/dmails_controller_test.rb | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/policies/dmail_policy.rb b/app/policies/dmail_policy.rb index 5d97e9916..152d0c549 100644 --- a/app/policies/dmail_policy.rb +++ b/app/policies/dmail_policy.rb @@ -16,6 +16,7 @@ class DmailPolicy < ApplicationPolicy end def show? + return true if user.is_owner? user.is_member? && (record.owner_id == user.id || record.valid_key?(request.params[:key])) end diff --git a/test/functional/dmails_controller_test.rb b/test/functional/dmails_controller_test.rb index 963868a64..bc2628920 100644 --- a/test/functional/dmails_controller_test.rb +++ b/test/functional/dmails_controller_test.rb @@ -99,6 +99,11 @@ class DmailsControllerTest < ActionDispatch::IntegrationTest assert_response 403 end + should "show dmails to the site owner" do + get_auth dmail_path(@dmail), create(:owner_user) + assert_response :success + end + should "mark dmails as read" do assert_equal(false, @dmail.is_read) get_auth dmail_path(@dmail), @dmail.owner From 23ee39010af61ec390d98076d4259da5ce3f90c0 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 03:00:43 -0600 Subject: [PATCH 024/132] Update ruby gems and yarn packages. --- Gemfile.lock | 24 ++-- yarn.lock | 376 +++++++++++++++++++++++---------------------------- 2 files changed, 184 insertions(+), 216 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1bab4040e..2fb7f8a00 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,8 +84,8 @@ GEM ansi (1.5.0) ast (2.4.1) aws-eventstream (1.1.0) - aws-partitions (1.402.0) - aws-sdk-core (3.109.3) + aws-partitions (1.405.0) + aws-sdk-core (3.110.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) @@ -135,10 +135,10 @@ GEM crass (1.0.6) daemons (1.3.1) dante (0.2.0) - delayed_job (4.1.8) - activesupport (>= 3.0, < 6.1) - delayed_job_active_record (4.1.4) - activerecord (>= 3.0, < 6.1) + delayed_job (4.1.9) + activesupport (>= 3.0, < 6.2) + delayed_job_active_record (4.1.5) + activerecord (>= 3.0, < 6.2) delayed_job (>= 3.0, < 5) diff-lcs (1.4.4) docile (1.3.2) @@ -242,7 +242,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.1.0) + puma (5.1.1) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) @@ -298,21 +298,21 @@ GEM actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.4) - rubocop (1.4.2) + rubocop (1.6.1) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8) + regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.1.1) + rubocop-ast (>= 1.2.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) rubocop-ast (1.3.0) parser (>= 2.7.1.5) - rubocop-rails (2.8.1) + rubocop-rails (2.9.0) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 0.87.0) + rubocop (>= 0.90.0, < 2.0) ruby-progressbar (1.10.1) ruby-vips (2.0.17) ffi (~> 1.9) diff --git a/yarn.lock b/yarn.lock index f52a73199..bba9711e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,42 +15,41 @@ integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== "@babel/core@>=7.9.0", "@babel/core@^7.11.1": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" - integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" + integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" + "@babel/generator" "^7.12.10" "@babel/helper-module-transforms" "^7.12.1" "@babel/helpers" "^7.12.5" - "@babel/parser" "^7.12.7" + "@babel/parser" "^7.12.10" "@babel/template" "^7.12.7" - "@babel/traverse" "^7.12.9" - "@babel/types" "^7.12.7" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" json5 "^2.1.2" lodash "^4.17.19" - resolve "^1.3.2" semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" - integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== +"@babel/generator@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.10.tgz#2b188fc329fb8e4f762181703beffc0fe6df3460" + integrity sha512-6mCdfhWgmqLdtTkhXjnIz0LcdVCd26wS2JXRtj2XY0u5klDsXBREA/pG5NVOuVnF2LUrBGNFtQkIqqTbblg0ww== dependencies: - "@babel/types" "^7.12.5" + "@babel/types" "^7.12.10" jsesc "^2.5.1" source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" - integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== +"@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" + integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" @@ -60,14 +59,14 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.12.4": - version "7.12.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz#55fc1ead5242caa0ca2875dcb8eed6d311e50f48" - integrity sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og== +"@babel/helper-builder-react-jsx-experimental@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.10.tgz#a58cb96a793dc0fcd5c9ed3bb36d62fdc60534c2" + integrity sha512-3Kcr2LGpL7CTRDTTYm1bzeor9qZbxbvU2AxsLA6mUG9gYarSfIKMK0UlU+azLWI+s0+BH768bwyaziWB2NOJlQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.12.1" - "@babel/types" "^7.12.1" + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" + "@babel/types" "^7.12.10" "@babel/helper-builder-react-jsx@^7.10.4": version "7.10.4" @@ -132,11 +131,11 @@ "@babel/types" "^7.10.4" "@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" @@ -175,11 +174,11 @@ lodash "^4.17.19" "@babel/helper-optimise-call-expression@^7.10.4": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.7.tgz#7f94ae5e08721a49467346aa04fd22f750033b9c" - integrity sha512-I5xc9oSJ2h59OwyUqjv95HRyzxj53DAubUERgQMrpcCEYQyToeHA+NEcUEsVWB4j53RDeskeBJ0SgRAYHDBckw== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: - "@babel/types" "^7.12.7" + "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" @@ -264,10 +263,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.12.7", "@babel/parser@^7.7.0": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" - integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg== +"@babel/parser@^7.12.10", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81" + integrity sha512-PJdRPwyoOqFAWfLytxrWwGrAxghCgh/yTNCYciOz8QgjflA7aZhECPZAa2VUedKg2+QMWkI0L9lynh2SNmNEgA== "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.1" @@ -668,12 +667,12 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-jsx@^7.10.4": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.7.tgz#8b14d45f6eccd41b7f924bcb65c021e9f0a06f7f" - integrity sha512-YFlTi6MEsclFAPIDNZYiCRbneg1MFGao9pPG9uD5htwE0vDbPaMUMeYd6itWjw7K4kro4UbdQf3ljmFl9y48dQ== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.10.tgz#a7af3097c73479123594c8c8fe39545abebd44e3" + integrity sha512-MM7/BC8QdHXM7Qc1wdnuk73R4gbuOpfrSUgfV/nODGc86sPY1tgmY2M9E9uAnf2e4DOIp8aKGWqgZfQxnTNGuw== dependencies: "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" + "@babel/helper-builder-react-jsx-experimental" "^7.12.10" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" @@ -692,13 +691,12 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-runtime@^7.11.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.12.1.tgz#04b792057eb460389ff6a4198e377614ea1e7ba5" - integrity sha512-Ac/H6G9FEIkS2tXsZjL4RAdS3L3WHxci0usAnz7laPWUmFiGtj7tIASChqKZMHTSQTQY6xDbOq+V1/vIq3QrWg== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.12.10.tgz#af0fded4e846c4b37078e8e5d06deac6cd848562" + integrity sha512-xOrUfzPxw7+WDm9igMgQCbO3cJKymX7dFdsgRr1eu9n3KjjyU4pptIXbXPseQDquw+W+RuJEJMHKHNsPNNm3CA== dependencies: - "@babel/helper-module-imports" "^7.12.1" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - resolve "^1.8.1" semver "^5.5.1" "@babel/plugin-transform-shorthand-properties@^7.12.1": @@ -730,10 +728,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-typeof-symbol@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" - integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== +"@babel/plugin-transform-typeof-symbol@^7.12.10": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" + integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -753,9 +751,9 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/preset-env@^7.11.0": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.7.tgz#54ea21dbe92caf6f10cb1a0a576adc4ebf094b55" - integrity sha512-OnNdfAr1FUQg7ksb7bmbKoby4qFOHw6DKWWUNB9KqnnCldxhxJlP+21dpyaWFmf2h0rTbOkXJtAGevY3XW1eew== + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.10.tgz#ca981b95f641f2610531bd71948656306905e6ab" + integrity sha512-Gz9hnBT/tGeTE2DBNDkD7BiWRELZt+8lSysHuDwmYXUIvtwZl0zI+D6mZgXZX0u8YBlLS4tmai9ONNY9tjRgRA== dependencies: "@babel/compat-data" "^7.12.7" "@babel/helper-compilation-targets" "^7.12.5" @@ -816,12 +814,12 @@ "@babel/plugin-transform-spread" "^7.12.1" "@babel/plugin-transform-sticky-regex" "^7.12.7" "@babel/plugin-transform-template-literals" "^7.12.1" - "@babel/plugin-transform-typeof-symbol" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.10" "@babel/plugin-transform-unicode-escapes" "^7.12.1" "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.12.7" - core-js-compat "^3.7.0" + "@babel/types" "^7.12.10" + core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": @@ -859,25 +857,25 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9", "@babel/traverse@^7.7.0": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" - integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== +"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.10.tgz#2d1f4041e8bf42ea099e5b2dc48d6a594c00017a" + integrity sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" + "@babel/generator" "^7.12.10" "@babel/helper-function-name" "^7.10.4" "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.7" - "@babel/types" "^7.12.7" + "@babel/parser" "^7.12.10" + "@babel/types" "^7.12.10" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.4.4", "@babel/types@^7.7.0": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" - integrity sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.4.4", "@babel/types@^7.7.0": + version "7.12.10" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260" + integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw== dependencies: "@babel/helper-validator-identifier" "^7.10.4" lodash "^4.17.19" @@ -888,10 +886,10 @@ resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" integrity sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== -"@eslint/eslintrc@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.1.tgz#f72069c330461a06684d119384435e12a5d76e3c" - integrity sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA== +"@eslint/eslintrc@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.2.tgz#d01fc791e2fc33e88a29d6f3dc7e93d0cd784b76" + integrity sha512-EfB5OHNYp1F4px/LI/FEnGylop7nOqkQ1LRzCM0KccA2U8tvV8w01KBv37LbO7nW4H+YhKyo2LcJhRwjjV17QQ== dependencies: ajv "^6.12.4" debug "^4.1.1" @@ -943,9 +941,9 @@ integrity sha512-ZpKr+WTb8zsajqgDkvCEWgp6d5eJT6Q63Ng2neTbzBO76Lbe91vX/iVIW9dikq+Fs3yEo+ls4cxeXABD2LtcbQ== "@rails/ujs@^6.0.2-1": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.0.3.tgz#e68a03278e30daea6a110aac5dfa33c60c53055d" - integrity sha512-CM9OEvoN9eXkaX7PXEnbsQLULJ97b9rVmwliZbz/iBOERLJ68Rk3ClJe+fQEMKU4CBZfky2lIRnfslOdUs9SLQ== + version "6.1.0" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.1.0.tgz#9a48df6511cb2b472c9f596c1f37dc0af022e751" + integrity sha512-kQNKyM4ePAc4u9eR1c4OqrbAHH+3SJXt++8izIjeaZeg+P7yBtgoF/dogMD/JPPowNC74ACFpM/4op0Ggp/fPw== "@rails/webpacker@^5.0.0": version "5.2.1" @@ -1071,9 +1069,9 @@ integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== "@types/node@*": - version "14.14.10" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.10.tgz#5958a82e41863cfc71f2307b3748e3491ba03785" - integrity sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ== + version "14.14.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.13.tgz#9e425079799322113ae8477297ae6ef51b8e0cdf" + integrity sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -1270,7 +1268,7 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-jsx@^5.2.0: +acorn-jsx@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== @@ -1835,14 +1833,14 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.14.7, browserslist@^4.6.4: - version "4.15.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.15.0.tgz#3d48bbca6a3f378e86102ffd017d9a03f122bdb0" - integrity sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ== +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.15.0, browserslist@^4.6.4: + version "4.16.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" + integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== dependencies: - caniuse-lite "^1.0.30001164" + caniuse-lite "^1.0.30001165" colorette "^1.2.1" - electron-to-chromium "^1.3.612" + electron-to-chromium "^1.3.621" escalade "^3.1.1" node-releases "^1.1.67" @@ -1976,9 +1974,9 @@ cache-base@^1.0.1: unset-value "^1.0.0" cacheable-lookup@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz#049fdc59dffdd4fc285e8f4f82936591bd59fec3" - integrity sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w== + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== cacheable-request@^7.0.1: version "7.0.1" @@ -2062,10 +2060,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001164: - version "1.0.30001164" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001164.tgz#5bbfd64ca605d43132f13cc7fdabb17c3036bfdc" - integrity sha512-G+A/tkf4bu0dSp9+duNiXc7bGds35DioCyC6vgK2m/rjA4Krpy5WeZgZyfH2f0wj2kI6yAWWucyap6oOwmY1mg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: + version "1.0.30001166" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001166.tgz#ca73e8747acfd16a4fd6c4b784f1b995f9698cf8" + integrity sha512-nCL4LzYK7F4mL0TjEMeYavafOGnBa98vTudH5c8lW9izUjnB99InG6pmC1ElAI1p0GlyZajv4ltUdFXvOHIl1A== case-sensitive-paths-webpack-plugin@^2.3.0: version "2.3.0" @@ -2444,23 +2442,23 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.7.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.0.tgz#3248c6826f4006793bd637db608bca6e4cd688b1" - integrity sha512-o9QKelQSxQMYWHXc/Gc4L8bx/4F7TTraE5rhuN8I7mKBt5dBIUpXpIR3omv70ebr8ST5R3PqbDQr+ZI3+Tt1FQ== +core-js-compat@^3.8.0: + version "3.8.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.1.tgz#8d1ddd341d660ba6194cbe0ce60f4c794c87a36e" + integrity sha512-a16TLmy9NVD1rkjUGbwuyWkiDoN0FDpAwrfLONvHFQx0D9k7J9y0srwMT8QP/Z6HE3MIFaVynEeYwZwPX1o5RQ== dependencies: - browserslist "^4.14.7" + browserslist "^4.15.0" semver "7.0.0" core-js-pure@^3.0.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.8.0.tgz#4cdd2eca37d49cda206b66e26204818dba77884a" - integrity sha512-fRjhg3NeouotRoIV0L1FdchA6CK7ZD+lyINyMoz19SyV+ROpC4noS1xItWHFtwZdlqfMfVPJEyEGdfri2bD1pA== + version "3.8.1" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.8.1.tgz#23f84048f366fdfcf52d3fd1c68fec349177d119" + integrity sha512-Se+LaxqXlVXGvmexKGPvnUIYC1jwXu1H6Pkyb3uBM5d8/NELMYCHs/4/roD7721NxrTLyv7e5nXd5/QLBO+10g== core-js@^3.6.5: - version "3.8.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.0.tgz#0fc2d4941cadf80538b030648bb64d230b4da0ce" - integrity sha512-W2VYNB0nwQQE7tKS7HzXd7r2y/y2SVJl4ga6oH/dnaLFzM0o2lB2P3zCkWj5Wc/zyMYjtgd5Hmhk0ObkQFZOIA== + version "3.8.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.1.tgz#f51523668ac8a294d1285c3b9db44025fda66d47" + integrity sha512-9Id2xHY1W7m8hCl8NkhQn5CufmF/WuR30BTRewvCXc1aZd3kMECwNZ69ndLbekKfakw9Rf2Xyc+QR6E7Gg+obg== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -3111,10 +3109,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.612: - version "1.3.615" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.615.tgz#50f523be4a04449410e9f3a694490814e602cd54" - integrity sha512-fNYTQXoUhNc6RmHDlGN4dgcLURSBIqQCN7ls6MuQ741+NJyLNRz8DxAC+pZpOKfRs6cfY0lv2kWdy8Oxf9j4+A== +electron-to-chromium@^1.3.621: + version "1.3.625" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.625.tgz#a7bd18da4dc732c180b2e95e0e296c0bf22f3bd6" + integrity sha512-CsLk/r0C9dAzVPa9QF74HIXduxaucsaRfqiOYvIv2PRhvyC6EOqc/KbpgToQuDVgPf3sNAFZi3iBu4vpGOwGag== elliptic@^6.5.3: version "6.5.3" @@ -3324,12 +3322,12 @@ eslint-visitor-keys@^2.0.0: integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@^7.0.0: - version "7.14.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.14.0.tgz#2d2cac1d28174c510a97b377f122a5507958e344" - integrity sha512-5YubdnPXrlrYAFCKybPuHIAH++PINe1pmKNc5wQRB9HSbqIK1ywAnntE3Wwua4giKu0bjligf1gLF6qxMGOYRA== + version "7.15.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.15.0.tgz#eb155fb8ed0865fcf5d903f76be2e5b6cd7e0bc7" + integrity sha512-Vr64xFDT8w30wFll643e7cGrIkPEU50yIiI36OdSIDoSGguIeaLzBo0vpGvzo9RECUqq7htURfwEtKqwytkqzA== dependencies: "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.2.1" + "@eslint/eslintrc" "^0.2.2" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -3339,10 +3337,10 @@ eslint@^7.0.0: eslint-scope "^5.1.1" eslint-utils "^2.1.0" eslint-visitor-keys "^2.0.0" - espree "^7.3.0" + espree "^7.3.1" esquery "^1.2.0" esutils "^2.0.2" - file-entry-cache "^5.0.1" + file-entry-cache "^6.0.0" functional-red-black-tree "^1.0.1" glob-parent "^5.0.0" globals "^12.1.0" @@ -3366,13 +3364,13 @@ eslint@^7.0.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^7.3.0: - version "7.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" - integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== +espree@^7.3.0, espree@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" + integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== dependencies: acorn "^7.4.0" - acorn-jsx "^5.2.0" + acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" esprima@^4.0.0: @@ -3639,13 +3637,6 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== -file-entry-cache@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" - integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== - dependencies: - flat-cache "^2.0.1" - file-entry-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" @@ -3762,15 +3753,6 @@ findup-sync@^3.0.0: micromatch "^3.0.4" resolve-dir "^1.0.1" -flat-cache@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" - integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== - dependencies: - flatted "^2.0.0" - rimraf "2.6.3" - write "1.0.3" - flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -3779,11 +3761,6 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flatted@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" - integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== - flatted@^3.0.4, flatted@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.0.tgz#a5d06b4a8b01e3a63771daa5cb7a1903e2e57067" @@ -3803,9 +3780,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" - integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== + version "1.13.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7" + integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg== for-in@^1.0.2: version "1.0.2" @@ -4329,9 +4306,9 @@ html-comment-regex@^1.1.0: integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== html-entities@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" - integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== + version "1.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.3.tgz#3dca638a43ee7de316fc23067398491152ad4736" + integrity sha512-/VulV3SYni1taM7a4RMdceqzJWR39gpZHjBwUnsCFKWV/GJkD14CJ5F7eWcZozmHJK0/f/H5U3b3SiPkuvxMgg== html-tags@^3.1.0: version "3.1.0" @@ -4576,9 +4553,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== internal-ip@^4.3.0: version "4.3.0" @@ -4646,9 +4623,11 @@ is-alphanumerical@^1.0.0: is-decimal "^1.0.0" is-arguments@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" - integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9" + integrity sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg== + dependencies: + call-bind "^1.0.0" is-arrayish@^0.2.1: version "0.2.1" @@ -4819,9 +4798,9 @@ is-natural-number@^4.0.1: integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= is-negative-zero@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" - integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== is-number@^3.0.0: version "3.0.0" @@ -4998,7 +4977,7 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@3.14.0, js-yaml@^3.13.1, js-yaml@^3.14.0: +js-yaml@3.14.0: version "3.14.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== @@ -5006,6 +4985,14 @@ js-yaml@3.14.0, js-yaml@^3.13.1, js-yaml@^3.14.0: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^3.13.1, js-yaml@^3.14.0: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -5364,19 +5351,20 @@ md5.js@^1.3.4: safe-buffer "^5.1.2" mdast-util-from-markdown@^0.8.0: - version "0.8.1" - resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.1.tgz#781371d493cac11212947226190270c15dc97116" - integrity sha512-qJXNcFcuCSPqUF0Tb0uYcFDIq67qwB3sxo9RPdf9vG8T90ViKnksFqdB/Coq2a7sTnxL/Ify2y7aIQXDkQFH0w== + version "0.8.4" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.4.tgz#2882100c1b9fc967d3f83806802f303666682d32" + integrity sha512-jj891B5pV2r63n2kBTFh8cRI2uR9LQHsXG1zSDqfhXkIlDzrTcIlbB5+5aaYEkl8vOPIOPLf8VT7Ere1wWTMdw== dependencies: "@types/mdast" "^3.0.0" - mdast-util-to-string "^1.0.0" - micromark "~2.10.0" + mdast-util-to-string "^2.0.0" + micromark "~2.11.0" parse-entities "^2.0.0" + unist-util-stringify-position "^2.0.0" -mdast-util-to-markdown@^0.5.0: - version "0.5.4" - resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.5.4.tgz#be680ed0c0e11a07d07c7adff9551eec09c1b0f9" - integrity sha512-0jQTkbWYx0HdEA/h++7faebJWr5JyBoBeiRf0u3F4F3QtnyyGaWIsOwo749kRb1ttKrLLr+wRtOkfou9yB0p6A== +mdast-util-to-markdown@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.1.tgz#0e07d3f871e056bffc38a0cf50c7298b56d9e0d6" + integrity sha512-4qJtZ0qdyYeexAXoOZiU0uHIFVncJAmCkHkSluAsvDaVWODtPyNEo9I1ns0T4ulxu2EHRH5u/bt1cV0pdHCX+A== dependencies: "@types/unist" "^2.0.0" longest-streak "^2.0.0" @@ -5385,11 +5373,6 @@ mdast-util-to-markdown@^0.5.0: repeat-string "^1.0.0" zwitch "^1.0.0" -mdast-util-to-string@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" - integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== - mdast-util-to-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" @@ -5479,10 +5462,10 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= -micromark@~2.10.0: - version "2.10.1" - resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.10.1.tgz#cd73f54e0656f10e633073db26b663a221a442a7" - integrity sha512-fUuVF8sC1X7wsCS29SYQ2ZfIZYbTymp0EYr6sab3idFjigFFjGa5UwoniPlV9tAgntjuapW1t9U+S0yDYeGKHQ== +micromark@~2.11.0: + version "2.11.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.2.tgz#e8b6a05f54697d2d3d27fc89600c6bc40dd05f35" + integrity sha512-IXuP76p2uj8uMg4FQc1cRE7lPCLsfAXuEfdjtdO55VRiFO1asrCSQ5g43NmPqFtRwzEnEhafRVzn2jg0UiKArQ== dependencies: debug "^4.0.0" parse-entities "^2.0.0" @@ -5721,11 +5704,16 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@2.1.2, ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -7572,11 +7560,11 @@ remark-parse@^9.0.0: mdast-util-from-markdown "^0.8.0" remark-stringify@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.0.tgz#8ba0c9e4167c42733832215a81550489759e3793" - integrity sha512-8x29DpTbVzEc6Dwb90qhxCtbZ6hmj3BxWWDpMhA+1WM4dOEGH5U5/GFe3Be5Hns5MvPSFAr1e2KSVtKZkK5nUw== + version "9.0.1" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894" + integrity sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg== dependencies: - mdast-util-to-markdown "^0.5.0" + mdast-util-to-markdown "^0.6.0" remark@^13.0.0: version "13.0.0" @@ -7609,11 +7597,6 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -replace-ext@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" - integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= - request@^2.87.0, request@^2.88.0: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" @@ -7700,7 +7683,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.17.0, resolve@^1.3.2, resolve@^1.8.1: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.17.0: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== @@ -7747,13 +7730,6 @@ rimraf@2, rimraf@^2.5.4, rimraf@^2.6.3: dependencies: glob "^7.1.3" -rimraf@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - rimraf@3.0.2, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -9249,13 +9225,12 @@ vfile-message@^2.0.0: unist-util-stringify-position "^2.0.0" vfile@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01" - integrity sha512-a/alcwCvtuc8OX92rqqo7PflxiCgXRFjdyoGVuYV+qbgCb0GgZJRvIgCD4+U/Kl1yhaRsaTwksF88xbPyGsgpw== + version "4.2.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.1.tgz#03f1dce28fc625c625bc6514350fbdb00fa9e624" + integrity sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA== dependencies: "@types/unist" "^2.0.0" is-buffer "^2.0.0" - replace-ext "1.0.0" unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" @@ -9506,13 +9481,6 @@ write-file-atomic@^3.0.3: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" - integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== - dependencies: - mkdirp "^0.5.1" - ws@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" @@ -9521,9 +9489,9 @@ ws@^6.2.1: async-limiter "~1.0.0" xregexp@^4.2.4: - version "4.4.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.4.0.tgz#29660f5d6567cd2ef981dd4a50cb05d22c10719d" - integrity sha512-83y4aa8o8o4NZe+L+46wpa+F1cWR/wCGOWI3tzqUso0w3/KAvXy0+Di7Oe/cbNMixDR4Jmi7NEybWU6ps25Wkg== + version "4.4.1" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.4.1.tgz#c84a88fa79e9ab18ca543959712094492185fe65" + integrity sha512-2u9HwfadaJaY9zHtRRnH6BY6CQVNQKkYm3oLtC9gJXXzfsbACg5X5e4EZZGVAH+YIfa+QA9lsFQTTe3HURF3ag== dependencies: "@babel/runtime-corejs3" "^7.12.1" From 852c67f1b792984612ba898943817012c219548c Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 14:13:11 -0600 Subject: [PATCH 025/132] users: fix #owner method. --- app/models/user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index ec14ed950..7ac2d8aea 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -193,7 +193,7 @@ class User < ApplicationRecord module ClassMethods def owner - User.find_by!(level: Levels::ADMIN) + User.find_by!(level: Levels::OWNER) end def system From 0150911343699c8215dc0d00a1e690cabeae4123 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 14:29:31 -0600 Subject: [PATCH 026/132] css: remove missing --dtext-expand-border-color var. --- app/javascript/src/styles/common/dtext.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/src/styles/common/dtext.scss b/app/javascript/src/styles/common/dtext.scss index 40627770d..5c628c9ba 100644 --- a/app/javascript/src/styles/common/dtext.scss +++ b/app/javascript/src/styles/common/dtext.scss @@ -97,7 +97,6 @@ div.prose { div.expandable-content { display: none; padding: 0.4em; - border-top: 1px solid var(--dtext-expand-border-color); > :last-child { margin-bottom: 0; From df1404b6731b64b0726adca1e7c47a4461c14a81 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 14:48:06 -0600 Subject: [PATCH 027/132] js: set SameSite=Lax on cookies set by Javascript. This is the new default for most browsers nowadays. Fixes a warning in Firefox about using SameSite=None without the Secure flag. --- app/javascript/src/javascripts/cookie.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/src/javascripts/cookie.js b/app/javascript/src/javascripts/cookie.js index 20a5e4b0b..1c457d11b 100644 --- a/app/javascript/src/javascripts/cookie.js +++ b/app/javascript/src/javascripts/cookie.js @@ -14,7 +14,7 @@ Cookie.put = function(name, value, days) { expires = "expires=" + date.toGMTString() + "; "; } - var new_val = name + "=" + encodeURIComponent(value) + "; " + expires + "path=/"; + var new_val = name + "=" + encodeURIComponent(value) + "; " + expires + "path=/; SameSite=Lax"; if (document.cookie.length < (4090 - new_val.length)) { document.cookie = new_val; return true; From 23f6b8a46d783b9c9eae8a8df41bc00a03a282fc Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 14:58:32 -0600 Subject: [PATCH 028/132] js: refactor Cookie.put. * Set Max-Age= flag instead of Expires= flag. * Set Secure flag when using HTTPS. * Extend default cookie lifetime from 1 year to 20 years. * Remove "session" expiration option (unused). * Remove max cookie size check. The cookie size check was previously added in #2518 to deal with running out of space due to tag scripts and blacklists. This should no longer happen since we no longer use cookies for these things. Remove the warning because it should never happen, we can't fix it if it does, and the user probably won't know how to fix it either. --- app/javascript/src/javascripts/cookie.js | 26 ++++++++---------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/app/javascript/src/javascripts/cookie.js b/app/javascript/src/javascripts/cookie.js index 1c457d11b..707d95389 100644 --- a/app/javascript/src/javascripts/cookie.js +++ b/app/javascript/src/javascripts/cookie.js @@ -1,27 +1,17 @@ -import Utility from "./utility"; - let Cookie = {}; -Cookie.put = function(name, value, days) { - var expires = ""; - if (days !== "session") { - if (!days) { - days = 365; - } +Cookie.put = function(name, value, max_age_in_days = 365 * 20) { + let cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax;`; - var date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - expires = "expires=" + date.toGMTString() + "; "; + if (max_age_in_days) { + cookie += ` Max-Age=${max_age_in_days * 24 * 60 * 60};` } - var new_val = name + "=" + encodeURIComponent(value) + "; " + expires + "path=/; SameSite=Lax"; - if (document.cookie.length < (4090 - new_val.length)) { - document.cookie = new_val; - return true; - } else { - Utility.error("You have too many cookies on this site. Consider deleting them all.") - return false; + if (location.protocol === "https:") { + cookie += " Secure;"; } + + document.cookie = cookie; } Cookie.raw_get = function(name) { From c02c31b966336a5e91cc4f3d0951d12f26d0fd88 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 16:48:21 -0600 Subject: [PATCH 029/132] autocomplete: recognize Japanese tags in autocomplete. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowing typing Japanese tags in autocomplete. For example, typing 東方 in autocomplete will be completed to the touhou tag. Typing ぶくぶ will complete to the bkub tag. This works using wiki page and artist other names. Effectively, any name listed as an other name in a wiki or artist page will be treated like an alias for autocomplete purposes. This is limited to non-ASCII other names, to prevent English other names from interfering with regular tag searches. --- app/logical/autocomplete_service.rb | 23 +++++++++++++++++++++- app/models/wiki_page.rb | 2 +- test/unit/autocomplete_service_test.rb | 27 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 833c016da..2d4f5d614 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -72,8 +72,11 @@ class AutocompleteService results = results.uniq.sort_by { |r| [r[:antecedent].length, -r[:post_count]] }.take(limit) elsif string.include?("*") results = tag_matches(string) + results = tag_other_name_matches(string) if results.blank? else - results = tag_matches(string + "*") + string += "*" + results = tag_matches(string) + results = tag_other_name_matches(string) if results.blank? results = tag_autocorrect_matches(string) if results.blank? end @@ -81,6 +84,8 @@ class AutocompleteService end def tag_matches(string) + return [] if string =~ /[^[:ascii:]]/ + name_matches = Tag.nonempty.name_matches(string).order(post_count: :desc).limit(limit) alias_matches = Tag.nonempty.alias_matches(string).order(post_count: :desc).limit(limit) union = "((#{name_matches.to_sql}) UNION (#{alias_matches.to_sql})) AS tags" @@ -100,6 +105,7 @@ class AutocompleteService end def tag_autocorrect_matches(string) + string = string.delete("*") tags = Tag.nonempty.autocorrect_matches(string).limit(limit) tags.map do |tag| @@ -107,6 +113,21 @@ class AutocompleteService end end + def tag_other_name_matches(string) + return [] unless string =~ /[^[:ascii:]]/ + + artists = Artist.undeleted.any_other_name_like(string) + wikis = WikiPage.undeleted.other_names_match(string) + tags = Tag.where(name: wikis.select(:title)).or(Tag.where(name: artists.select(:name))) + tags = tags.nonempty.order(post_count: :desc).limit(limit).includes(:wiki_page, :artist) + + tags.map do |tag| + other_names = tag.artist&.other_names.to_a + tag.wiki_page&.other_names.to_a + antecedent = other_names.find { |other_name| other_name.ilike?(string) } + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent } + end + end + def autocomplete_metatag(metatag, value) results = case metatag.to_sym when :user, :approver, :commenter, :comm, :noter, :noteupdater, :commentaryupdater, diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index c9fd1a7c1..603ddcecd 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -45,7 +45,7 @@ class WikiPage < ApplicationRecord def other_names_match(name) if name =~ /\*/ - subquery = WikiPage.from("unnest(other_names) AS other_name").where_ilike("other_name", name) + subquery = WikiPage.from("unnest(other_names) AS other_name").where_ilike("other_name", normalize_other_name(name)) where(id: subquery) else other_names_include(name) diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index 29d8ea6ce..eaf65235b 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -97,6 +97,33 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) end + should "autocomplete tags from wiki and artist other names" do + create(:tag, name: "touhou") + create(:tag, name: "bkub", category: Tag.categories.artist) + create(:wiki_page, title: "touhou", other_names: %w[東方 东方 동방]) + create(:artist, name: "bkub", other_names: %w[大川ぶくぶ フミンバイン]) + + assert_autocomplete_equals(["touhou"], "東", :tag_query) + assert_autocomplete_equals(["touhou"], "东", :tag_query) + assert_autocomplete_equals(["touhou"], "동", :tag_query) + + assert_autocomplete_equals(["touhou"], "*東*", :tag_query) + assert_autocomplete_equals(["touhou"], "東*", :tag_query) + assert_autocomplete_equals([], "*東", :tag_query) + + assert_autocomplete_equals(["touhou"], "*方*", :tag_query) + assert_autocomplete_equals(["touhou"], "*方", :tag_query) + assert_autocomplete_equals([], "方", :tag_query) + + assert_autocomplete_equals(["bkub"], "*大*", :tag_query) + assert_autocomplete_equals(["bkub"], "大", :tag_query) + assert_autocomplete_equals([], "*大", :tag_query) + + assert_autocomplete_equals(["bkub"], "*川*", :tag_query) + assert_autocomplete_equals([], "*川", :tag_query) + assert_autocomplete_equals([], "川", :tag_query) + end + should "autocomplete wildcard searches" do create(:tag, name: "mole", post_count: 150) create(:tag, name: "mole_under_eye", post_count: 100) From 4cdaf7bcdfdb983b1f0648af2b8eb527e8118b7f Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 17:40:41 -0600 Subject: [PATCH 030/132] autocomplete: update html data attributes. * Remove the `source` and `weight` html data attributes (no longer used). * Make the `type` html data attribute properly indicate the completion type. Valid types: `tag`, `tag-alias`, `tag-abbreviation`, `tag-autocorrect`, `tag-other-name`. --- app/javascript/src/javascripts/autocomplete.js.erb | 6 ++---- app/javascript/src/styles/common/autocomplete.scss | 2 +- app/logical/autocomplete_service.rb | 10 ++++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index 2d4818a95..de025cd66 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -180,10 +180,8 @@ Autocomplete.render_item = function(list, item) { $link.append($post_count); } - if (item.type === "tag") { + if (/^tag/.test(item.type)) { $link.addClass("tag-type-" + item.category); - } else if (item.type === "tag_autocorrect") { - $link.addClass(`tag-type-${item.category} tag-type-autocorrect`); } else if (item.type === "user") { var level_class = "user-" + item.level.toLowerCase(); $link.addClass(level_class); @@ -194,7 +192,7 @@ Autocomplete.render_item = function(list, item) { var $menu_item = $("
    ").append($link); var $list_item = $("
  • ").data("item.autocomplete", item).append($menu_item); - var data_attributes = ["type", "source", "antecedent", "value", "category", "post_count", "weight"]; + var data_attributes = ["type", "antecedent", "value", "category", "post_count"]; data_attributes.forEach(attr => { $list_item.attr(`data-autocomplete-${attr.replace(/_/g, "-")}`, item[attr]); }); diff --git a/app/javascript/src/styles/common/autocomplete.scss b/app/javascript/src/styles/common/autocomplete.scss index 44158a614..e8a30e453 100644 --- a/app/javascript/src/styles/common/autocomplete.scss +++ b/app/javascript/src/styles/common/autocomplete.scss @@ -22,7 +22,7 @@ color: var(--autocomplete-arrow-color); } - a.tag-type-autocorrect .autocomplete-antecedent { + li[data-autocomplete-type="tag-autocorrect"] .autocomplete-antecedent { text-decoration: dotted underline; } } diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 2d4f5d614..845376885 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -92,7 +92,9 @@ class AutocompleteService tags = Tag.from(union).order(post_count: :desc).limit(limit).includes(:consequent_aliases) tags.map do |tag| - { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: tag.tag_alias_for_pattern(string)&.antecedent_name } + antecedent = tag.tag_alias_for_pattern(string)&.antecedent_name + type = antecedent.present? ? "tag-alias" : "tag" + { type: type, label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent } end end @@ -100,7 +102,7 @@ class AutocompleteService tags = Tag.nonempty.abbreviation_matches(string).order(post_count: :desc).limit(limit) tags.map do |tag| - { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation } + { type: "tag-abbreviation", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation } end end @@ -109,7 +111,7 @@ class AutocompleteService tags = Tag.nonempty.autocorrect_matches(string).limit(limit) tags.map do |tag| - { type: "tag_autocorrect", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: string } + { type: "tag-autocorrect", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: string } end end @@ -124,7 +126,7 @@ class AutocompleteService tags.map do |tag| other_names = tag.artist&.other_names.to_a + tag.wiki_page&.other_names.to_a antecedent = other_names.find { |other_name| other_name.ilike?(string) } - { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent } + { type: "tag-other-name", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent } end end From cf70a651f03a1dc0b8c815094a697350a1497625 Mon Sep 17 00:00:00 2001 From: Vladimir-A <32281993+Vladimir-A@users.noreply.github.com> Date: Tue, 15 Dec 2020 05:01:25 +0300 Subject: [PATCH 031/132] Add: apt-get -y install gcc g++ gcc, g ++ is required to building --- INSTALL.debian | 1 + 1 file changed, 1 insertion(+) diff --git a/INSTALL.debian b/INSTALL.debian index c435d7748..f158d1266 100644 --- a/INSTALL.debian +++ b/INSTALL.debian @@ -48,6 +48,7 @@ apt-get -y install zlib1g-dev libglib2.0-dev apt-get -y install $LIBSSL_DEV_PKG build-essential automake libxml2-dev libxslt-dev ncurses-dev sudo libreadline-dev flex bison ragel redis git curl libcurl4-openssl-dev sendmail-bin sendmail nginx ssh coreutils ffmpeg mkvtoolnix apt-get -y install libpq-dev postgresql-client apt-get -y install liblcms2-dev $LIBJPEG_TURBO_DEV_PKG libexpat1-dev libgif-dev libpng-dev libexif-dev +apt-get -y install gcc g++ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list From 0f44d8b974d9f7421ba281d6ac358672731449b9 Mon Sep 17 00:00:00 2001 From: Vladimir-A <32281993+Vladimir-A@users.noreply.github.com> Date: Tue, 15 Dec 2020 05:15:57 +0300 Subject: [PATCH 032/132] Change: regular expressions. Add [^ -] to exclude duplicate. Example: PostgreSQL 11.9 (Debian 11.9-0+deb10u1) --- INSTALL.debian | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.debian b/INSTALL.debian index f158d1266..a471b1eeb 100644 --- a/INSTALL.debian +++ b/INSTALL.debian @@ -77,7 +77,7 @@ chsh -s /bin/bash danbooru usermod -G danbooru,sudo danbooru # Set up Postgres -export PG_VERSION=`pg_config --version | egrep -o '[0-9]{1,}\.[0-9]{1,}'` +export PG_VERSION=`pg_config --version | egrep -o '[0-9]{1,}\.[0-9]{1,}[^-]'` if verlte 9.5 $PG_VERSION ; then # only do this on postgres 9.5 and above From 26246b0ac96ac5d3d09f3a67e2f8d8bc1836576b Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 14 Dec 2020 21:57:28 -0600 Subject: [PATCH 033/132] autocomplete: fix exception when typing "/" in autocomplete. Fix an exception that could occur when typing "/" by itself in autocomplete and a regular tag starting with "/" was returned. This caused an exception in `r[:antecedent].length` because the tag's antecedent was nil. --- app/logical/autocomplete_service.rb | 3 ++- test/unit/autocomplete_service_test.rb | 34 +++++++++++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 845376885..0c5d39f93 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -69,7 +69,8 @@ class AutocompleteService string = string + "*" unless string.include?("*") results = tag_matches(string) results += tag_abbreviation_matches(string) - results = results.uniq.sort_by { |r| [r[:antecedent].length, -r[:post_count]] }.take(limit) + results = results.sort_by { |r| [r[:antecedent].to_s.size, -r[:post_count]] } + results = results.uniq { |r| r[:value] }.take(limit) elsif string.include?("*") results = tag_matches(string) results = tag_other_name_matches(string) if results.blank? diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index eaf65235b..de2e547b3 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -82,19 +82,31 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("touhou", "~tou", :tag_query) end - should "autocomplete tag abbreviations" do - create(:tag, name: "mole", post_count: 150) - create(:tag, name: "mole_under_eye", post_count: 100) - create(:tag, name: "mole_under_mouth", post_count: 50) + context "for a tag abbreviation" do + should "autocomplete abbreviations" do + create(:tag, name: "mole", post_count: 150) + create(:tag, name: "mole_under_eye", post_count: 100) + create(:tag, name: "mole_under_mouth", post_count: 50) - assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "/m", :tag_query) - assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "/mu", :tag_query) - assert_autocomplete_equals(%w[mole_under_mouth], "/mum", :tag_query) - assert_autocomplete_equals(%w[mole_under_eye], "/mue", :tag_query) - assert_autocomplete_equals(%w[mole_under_eye], "/*ue", :tag_query) + assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "/m", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "/mu", :tag_query) + assert_autocomplete_equals(%w[mole_under_mouth], "/mum", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/mue", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/*ue", :tag_query) - assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) - assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) + assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) + assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) + end + + should "work for regular tags starting with a /" do + create(:tag, name: "jojo_pose", post_count: 100) + create(:tag, name: "/jp/", post_count: 50) + + assert_autocomplete_equals(%w[/jp/ jojo_pose], "/", :tag_query) + assert_autocomplete_equals(%w[/jp/ jojo_pose], "/j", :tag_query) + assert_autocomplete_equals(%w[/jp/ jojo_pose], "/jp", :tag_query) + assert_autocomplete_equals(%w[/jp/], "/jp/", :tag_query) + end end should "autocomplete tags from wiki and artist other names" do From c836c93b818ee6a56d3a8ece36eb380f42ba5bf3 Mon Sep 17 00:00:00 2001 From: evazion Date: Tue, 15 Dec 2020 03:43:13 -0600 Subject: [PATCH 034/132] autocomplete: don't send cookies in publicly cached responses. Fix session cookies being sent in publicly cached /autocomplete.json responses. We can't set any cookies in a response that is being publicly cached, otherwise they'll be visible to other users. If a user's session cookies were to be cached, then it would allow their account to be stolen. In reality, well-behaved caches like Cloudflare will simply refuse to cache responses that contain cookies to avoid this scenario. https://support.cloudflare.com/hc/en-us/articles/200172516-Understanding-Cloudflare-s-CDN: BYPASS is returned when enabling Origin Cache-Control. Cloudflare also sets BYPASS when your origin web server sends cookies in the response header. --- app/controllers/application_controller.rb | 9 +++++++++ test/functional/autocomplete_controller_test.rb | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9afec6b03..8f6faeb53 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,6 +15,7 @@ class ApplicationController < ActionController::Base before_action :set_variant before_action :add_headers before_action :cause_error + after_action :skip_session_if_publicly_cached after_action :reset_current_user layout "default" @@ -148,6 +149,14 @@ class ApplicationController < ActionController::Base CurrentUser.root_url = root_url.chomp("/") end + # Skip setting the session cookie if the response is being publicly cached to + # prevent the user's session cookie from being leaked to other users. + def skip_session_if_publicly_cached + if response.cache_control[:public] == true + request.session_options[:skip] = true + end + end + def set_variant request.variant = params[:variant].try(:to_sym) end diff --git a/test/functional/autocomplete_controller_test.rb b/test/functional/autocomplete_controller_test.rb index 4dd62ee1a..ae372ab1f 100644 --- a/test/functional/autocomplete_controller_test.rb +++ b/test/functional/autocomplete_controller_test.rb @@ -34,6 +34,14 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest assert_autocomplete_equals(["rating:safe"], "rating:s", "tag_query") assert_autocomplete_equals(["rating:safe"], "-rating:s", "tag_query") end + + should "not set session cookies when the response is publicly cached" do + get autocomplete_index_path(search: { query: "azur", type: "tag_query" }), as: :json + + assert_response :success + assert_equal(true, response.cache_control[:public]) + assert_equal({}, response.cookies) + end end end end From 0d83106a215d27288d3db00fcf102dba41df94cf Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 16 Dec 2020 01:31:32 -0600 Subject: [PATCH 035/132] autocomplete: fix cache issue related to content negotiation. This is the scenario: * You type something in autocomplete, let's say 'touhou'. * Autocomplete calls /autocomplete?search[query]=touhou&search[type]=tag_query * The endpoint returns JSON, because the autocomplete call sets an `Accept: application/json` header requesting JSON. * Visit /autocomplete?search[query]=touhou&search[type]=tag_query in your browser. * Notice that the cached JSON response is incorrectly returned, not an HTML response like the browser requested. The problem is that the response type is chosen based on the Accept header, but the response didn't set the `Vary: Accept` header, so the browser doesn't know the response type can vary and so it incorrectly returns the cached response. This issue is partially fixed by Rails 6.1 ([1]), which properly sets the `Vary: Accept` header when the response depends on the Accept header. However, the next issue is that Cloudflare doesn't respect the Vary header at all ([2], [3]). Therefore we can't use the Accept header to pick the format, instead we have explicitly specify the format with /autocomplete.json. This is clearer and better for caching anyway. Using the `Vary: Accept` header reduces the cache hit rate, because the exact format of the Accept header varies across browsers, which fragments the cache. Whew. [1] https://github.com/rails/rails/pull.36213 [2] https://community.cloudflare.com/t/cloudflare-cdn-cache-to-support-http-vary-header/160802 [3] https://support.cloudflare.com/hc/en-us/articles/115003206852 [4] https://www.smashingmagazine.com/2017/11/understanding-vary-header/ --- app/javascript/src/javascripts/autocomplete.js.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index de025cd66..4046ccc7d 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -201,7 +201,7 @@ Autocomplete.render_item = function(list, item) { }; Autocomplete.autocomplete_source = function(query, type) { - return $.getJSON("/autocomplete", { + return $.getJSON("/autocomplete.json", { "search[query]": query, "search[type]": type, "limit": Autocomplete.MAX_RESULTS From 6b966689b071eb8bb030b6751c1e22833a28cff2 Mon Sep 17 00:00:00 2001 From: nonamethanks Date: Wed, 16 Dec 2020 13:42:25 +0100 Subject: [PATCH 036/132] Blacklist pixiv en urls from artist finder --- app/logical/artist_finder.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/logical/artist_finder.rb b/app/logical/artist_finder.rb index a0e8f21b9..205a3cd9a 100644 --- a/app/logical/artist_finder.rb +++ b/app/logical/artist_finder.rb @@ -83,8 +83,8 @@ module ArtistFinder "pixiv.net", # https://www.pixiv.net/member.php?id=10442390 "pixiv.net/stacc", # https://www.pixiv.net/stacc/aaaninja2013 "pixiv.net/fanbox/creator", # https://www.pixiv.net/fanbox/creator/310630 - "pixiv.net/users", # https://www.pixiv.net/users/555603 - "pixiv.net/en/users", # https://www.pixiv.net/en/users/555603 + %r{pixiv.net/(?:en/)?users}i, # https://www.pixiv.net/users/555603 + %r{pixiv.net/(?:en/)?artworks}i, # https://www.pixiv.net/en/artworks/85241178 "i.pximg.net", "plurk.com", # http://www.plurk.com/a1amorea1a1 "privatter.net", From 25682ebf4669f764ee65d3d485883d71569ef820 Mon Sep 17 00:00:00 2001 From: nonamethanks Date: Wed, 16 Dec 2020 13:43:50 +0100 Subject: [PATCH 037/132] Blacklist baraag.net root from artist finder --- app/logical/artist_finder.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/logical/artist_finder.rb b/app/logical/artist_finder.rb index 205a3cd9a..a9ba53edf 100644 --- a/app/logical/artist_finder.rb +++ b/app/logical/artist_finder.rb @@ -8,6 +8,7 @@ module ArtistFinder "www.artstation.com", # http://www.artstation.com/serafleur/ %r{cdn[ab]?\.artstation\.com/p/assets/images/images}i, # https://cdna.artstation.com/p/assets/images/images/001/658/068/large/yang-waterkuma-b402.jpg?1450269769 "ask.fm", # http://ask.fm/mikuroko_396 + "baraag.net", "bcyimg.com", "bcyimg.com/drawer", # https://img9.bcyimg.com/drawer/32360/post/178vu/46229ec06e8111e79558c1b725ebc9e6.jpg "bcy.net", From 3801e08ae6eee7ea389f9d50ce9d383fa62072ee Mon Sep 17 00:00:00 2001 From: nonamethanks Date: Wed, 16 Dec 2020 13:53:16 +0100 Subject: [PATCH 038/132] Update pixiv url matching tests --- test/unit/artist_test.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit/artist_test.rb b/test/unit/artist_test.rb index 3650b05af..a7fefeb7e 100644 --- a/test/unit/artist_test.rb +++ b/test/unit/artist_test.rb @@ -164,6 +164,13 @@ class ArtistTest < ActiveSupport::TestCase assert_artist_not_found("http://i2.pixiv.net/img28/img/kyang692/35563903.jpg") end + should "ignore /en/ pixiv url matches" do + a1 = FactoryBot.create(:artist, :name => "vvv", :url_string => "https://www.pixiv.net/en/users/32072927/artworks") + a2 = FactoryBot.create(:artist, :name => "c01a", :url_string => "https://www.pixiv.net/en/users/31744504") + assert_artist_not_found("https://www.pixiv.net/en/artworks/85241178") + assert_artist_not_found("https://www.pixiv.net/en/users/85241178") + end + should "find matches by url" do a1 = FactoryBot.create(:artist, :name => "rembrandt", :url_string => "http://rembrandt.com/x/test.jpg") a2 = FactoryBot.create(:artist, :name => "subway", :url_string => "http://subway.com/x/test.jpg") From 2297bf5da58c3c83c9dcff8ddff21d5303e4e322 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 16 Dec 2020 20:03:09 -0600 Subject: [PATCH 039/132] Fix #4638: Add exclusions to the numeric attributes. Add the following search operators: * /tags?search[post_count_eq]=42 * /tags?search[post_count_not_eq]=42 * /tags?search[post_count_gt]=42 * /tags?search[post_count_gteq]=42 * /tags?search[post_count_lt]=42 * /tags?search[post_count_lteq]=42 Works for all numeric attributes on all index actions. --- app/logical/concerns/searchable.rb | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index 3cde3b5ec..4b64261eb 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -183,7 +183,7 @@ module Searchable when :boolean search_boolean_attribute(name, params) when :integer, :datetime - numeric_attribute_matches(name, params[name]) + search_numeric_attribute(name, params) when :inet search_inet_attribute(name, params) when :enum @@ -196,6 +196,26 @@ module Searchable end end + def search_numeric_attribute(attr, params) + if params[attr].present? + numeric_attribute_matches(attr, params[attr]) + elsif params[:"#{attr}_eq"].present? + where_operator(attr, :eq, params[:"#{attr}_eq"]) + elsif params[:"#{attr}_not_eq"].present? + where_operator(attr, :not_eq, params[:"#{attr}_not_eq"]) + elsif params[:"#{attr}_gt"].present? + where_operator(attr, :gt, params[:"#{attr}_gt"]) + elsif params[:"#{attr}_gteq"].present? + where_operator(attr, :gteq, params[:"#{attr}_gteq"]) + elsif params[:"#{attr}_lt"].present? + where_operator(attr, :lt, params[:"#{attr}_lt"]) + elsif params[:"#{attr}_lteq"].present? + where_operator(attr, :lteq, params[:"#{attr}_lteq"]) + else + all + end + end + def search_text_attribute(attr, params) if params[attr].present? where(attr => params[attr]) From b0659eb76ca382593f22a0e1f0213b614a797f02 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 16 Dec 2020 21:11:16 -0600 Subject: [PATCH 040/132] searchable: add tests for Searchable concern. --- test/unit/concerns/searchable.rb | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/unit/concerns/searchable.rb diff --git a/test/unit/concerns/searchable.rb b/test/unit/concerns/searchable.rb new file mode 100644 index 000000000..8dace9e16 --- /dev/null +++ b/test/unit/concerns/searchable.rb @@ -0,0 +1,124 @@ +require 'test_helper' + +class SearchableTest < ActiveSupport::TestCase + def assert_search_equals(results, **params) + assert_equal(Array(results).map(&:id), subject.search(**params).ids) + end + + context "#search method" do + subject { Post } + + setup do + @p1 = create(:post, source: "a1", score: 1, is_deleted: true, uploader_ip_addr: "10.0.0.1") + @p2 = create(:post, source: "b2", score: 2, is_deleted: false) + @p3 = create(:post, source: "c3", score: 3, is_deleted: false) + end + + context "for a numeric attribute" do + should "support basic operators" do + assert_search_equals(@p1, score_eq: 1) + assert_search_equals(@p3, score_gt: 2) + assert_search_equals(@p1, score_lt: 2) + assert_search_equals([@p3, @p1], score_not_eq: 2) + assert_search_equals([@p3, @p2], score_gteq: 2) + assert_search_equals([@p2, @p1], score_lteq: 2) + end + + should "support embedded expressions" do + assert_search_equals(@p1, score: "1") + assert_search_equals(@p3, score: ">2") + assert_search_equals(@p1, score: "<2") + assert_search_equals([@p3, @p2], score: ">=2") + assert_search_equals([@p2, @p1], score: "<=2") + assert_search_equals([@p3, @p2], score: "3,2") + assert_search_equals([@p2, @p1], score: "1...3") + assert_search_equals([@p2, @p1], score: "3...1") + assert_search_equals([@p3, @p2, @p1], score: "1..3") + assert_search_equals([@p3, @p2, @p1], score: "3..1") + end + end + + context "for a string attribute" do + should "support various operators" do + assert_search_equals(@p1, source: "a1") + assert_search_equals(@p1, source_eq: "a1") + assert_search_equals(@p1, source_like: "a*") + assert_search_equals(@p1, source_ilike: "A*") + assert_search_equals(@p1, source_regex: "^a.*") + + assert_search_equals(@p1, source_array: ["a1", "blah"]) + assert_search_equals(@p1, source_comma: "a1,blah") + assert_search_equals(@p1, source_space: "a1 blah") + assert_search_equals(@p1, source_lower_array: ["a1", "BLAH"]) + assert_search_equals(@p1, source_lower_comma: "a1,BLAH") + assert_search_equals(@p1, source_lower_space: "a1 BLAH") + + assert_search_equals([@p3, @p2], source_not_eq: "a1") + assert_search_equals([@p3, @p2], source_not_like: "a*") + assert_search_equals([@p3, @p2], source_not_ilike: "A*") + assert_search_equals([@p3, @p2], source_not_regex: "^a.*") + end + end + + context "for a boolean attribute" do + should "work" do + assert_search_equals(@p1, is_deleted: "true") + assert_search_equals(@p1, is_deleted: "yes") + assert_search_equals(@p1, is_deleted: "on") + assert_search_equals(@p1, is_deleted: "1") + + assert_search_equals([@p3, @p2], is_deleted: "false") + assert_search_equals([@p3, @p2], is_deleted: "no") + assert_search_equals([@p3, @p2], is_deleted: "off") + assert_search_equals([@p3, @p2], is_deleted: "0") + end + end + + context "for an inet attribute" do + should "work" do + assert_search_equals(@p1, uploader_ip_addr: "10.0.0.1") + assert_search_equals(@p1, uploader_ip_addr: "10.0.0.1/24") + assert_search_equals(@p1, uploader_ip_addr: "10.0.0.1,1.1.1.1") + assert_search_equals(@p1, uploader_ip_addr: "10.0.0.1 1.1.1.1") + end + end + + context "for an enum attribute" do + subject { PostFlag } + + should "work" do + @pf = create(:post_flag, status: :pending) + + assert_search_equals(@pf, status: "pending") + assert_search_equals(@pf, status: "pending,blah") + assert_search_equals(@pf, status: "pending blah") + assert_search_equals(@pf, status_id: 0) + end + end + + context "for an array attribute" do + subject { WikiPage } + + should "work" do + @wp = create(:wiki_page, other_names: ["a1", "b2"]) + + assert_search_equals(@wp, other_names_include_any: "a1") + assert_search_equals(@wp, other_names_include_any: "a1 blah") + + assert_search_equals(@wp, other_names_include_all: "a1") + assert_search_equals(@wp, other_names_include_all: "a1 b2") + + assert_search_equals(@wp, other_names_include_any_array: ["a1", "blah"]) + assert_search_equals(@wp, other_names_include_all_array: ["a1", "b2"]) + + assert_search_equals(@wp, other_names_include_any_lower: "A1 BLAH") + assert_search_equals(@wp, other_names_include_all_lower: "A1 B2") + + assert_search_equals(@wp, other_names_include_any_lower_array: ["A1", "BLAH"]) + assert_search_equals(@wp, other_names_include_all_lower_array: ["A1", "B2"]) + + assert_search_equals(@wp, other_name_count: 2) + end + end + end +end From e771c0fca820dadafe40fa72a3d4f6364da8f27e Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 16 Dec 2020 22:00:22 -0600 Subject: [PATCH 041/132] searchable: don't automatically include id, created_at, updated_at. Don't make search methods on models call super in order to search certain default attributes (id, created_at, updated_at). Simplifies some magic. --- app/logical/concerns/searchable.rb | 9 +-------- app/models/artist.rb | 4 +--- app/models/artist_commentary.rb | 4 +--- app/models/artist_commentary_version.rb | 3 +-- app/models/artist_url.rb | 4 +--- app/models/artist_version.rb | 4 +--- app/models/ban.rb | 4 +--- app/models/bulk_update_request.rb | 4 +--- app/models/comment.rb | 4 +--- app/models/comment_vote.rb | 3 +-- app/models/dmail.rb | 4 +--- app/models/dtext_link.rb | 3 +-- app/models/email_address.rb | 4 +--- app/models/favorite.rb | 3 +-- app/models/favorite_group.rb | 3 +-- app/models/forum_post.rb | 3 +-- app/models/forum_post_vote.rb | 3 +-- app/models/forum_topic.rb | 3 +-- app/models/forum_topic_visit.rb | 3 +-- app/models/ip_address.rb | 3 +-- app/models/ip_ban.rb | 3 +-- app/models/mod_action.rb | 4 +--- app/models/moderation_report.rb | 3 +-- app/models/note.rb | 4 +--- app/models/note_version.rb | 4 +--- app/models/pixiv_ugoira_frame_data.rb | 3 +-- app/models/pool.rb | 4 +--- app/models/pool_version.rb | 3 +-- app/models/post.rb | 14 +++++++------- app/models/post_appeal.rb | 3 +-- app/models/post_approval.rb | 2 +- app/models/post_disapproval.rb | 4 +--- app/models/post_flag.rb | 4 +--- app/models/post_replacement.rb | 3 +-- app/models/post_version.rb | 3 +-- app/models/post_vote.rb | 3 +-- app/models/saved_search.rb | 3 +-- app/models/tag.rb | 4 +--- app/models/tag_relationship.rb | 3 +-- app/models/upload.rb | 4 +--- app/models/user.rb | 4 +--- app/models/user_feedback.rb | 4 +--- app/models/user_name_change_request.rb | 3 +-- app/models/wiki_page.rb | 4 +--- app/models/wiki_page_version.rb | 4 +--- 45 files changed, 51 insertions(+), 121 deletions(-) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index 4b64261eb..321d89a81 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -155,6 +155,7 @@ module Searchable indifferent_params = params.try(:with_indifferent_access) || params.try(:to_unsafe_h) raise ArgumentError, "unable to process params" if indifferent_params.nil? + attributes += searchable_includes attributes.reduce(all) do |relation, attribute| relation.search_attribute(attribute, indifferent_params, CurrentUser.user) end @@ -406,14 +407,6 @@ module Searchable where(id: ids).order(Arel.sql(order_clause.join(', '))) end - def search(params = {}) - params ||= {} - - default_attributes = (attribute_names.map(&:to_sym) & %i[id created_at updated_at]) - all_attributes = default_attributes + searchable_includes - search_attributes(params, *all_attributes) - end - private def qualified_column_for(attr) diff --git a/app/models/artist.rb b/app/models/artist.rb index 6a2222c65..9ee09c407 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -250,9 +250,7 @@ class Artist < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :is_deleted, :is_banned, :name, :group_name, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :other_names) if params[:any_other_name_like] q = q.any_other_name_like(params[:any_other_name_like]) diff --git a/app/models/artist_commentary.rb b/app/models/artist_commentary.rb index d94939d22..1ea297c62 100644 --- a/app/models/artist_commentary.rb +++ b/app/models/artist_commentary.rb @@ -31,9 +31,7 @@ class ArtistCommentary < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :original_title, :original_description, :translated_title, :translated_description) + q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description) if params[:text_matches].present? q = q.text_matches(params[:text_matches]) diff --git a/app/models/artist_commentary_version.rb b/app/models/artist_commentary_version.rb index 1bf34aa02..c9fcb9a49 100644 --- a/app/models/artist_commentary_version.rb +++ b/app/models/artist_commentary_version.rb @@ -12,8 +12,7 @@ class ArtistCommentaryVersion < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :original_title, :original_description, :translated_title, :translated_description) + q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description) if params[:text_matches].present? q = q.text_matches(params[:text_matches]) diff --git a/app/models/artist_url.rb b/app/models/artist_url.rb index bab5e04c3..bd17c7358 100644 --- a/app/models/artist_url.rb +++ b/app/models/artist_url.rb @@ -40,9 +40,7 @@ class ArtistUrl < ApplicationRecord end def self.search(params = {}) - q = super - - q = q.search_attributes(params, :url, :normalized_url, :is_active) + q = search_attributes(params, :id, :created_at, :updated_at, :url, :normalized_url, :is_active) q = q.url_matches(params[:url_matches]) q = q.normalized_url_matches(params[:normalized_url_matches]) diff --git a/app/models/artist_version.rb b/app/models/artist_version.rb index 19d608e6b..c7af5690a 100644 --- a/app/models/artist_version.rb +++ b/app/models/artist_version.rb @@ -7,9 +7,7 @@ class ArtistVersion < ApplicationRecord module SearchMethods def search(params) - q = super - - q = q.search_attributes(params, :is_deleted, :is_banned, :name, :group_name, :urls, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :urls, :other_names) q = q.text_attribute_matches(:name, params[:name_matches]) q = q.text_attribute_matches(:group_name, params[:group_name_matches]) diff --git a/app/models/ban.rb b/app/models/ban.rb index 4d91d04ae..6417ba739 100644 --- a/app/models/ban.rb +++ b/app/models/ban.rb @@ -20,9 +20,7 @@ class Ban < ApplicationRecord end def self.search(params) - q = super - - q = q.search_attributes(params, :expires_at, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :expires_at, :reason) q = q.text_attribute_matches(:reason, params[:reason_matches]) q = q.expired if params[:expired].to_s.truthy? diff --git a/app/models/bulk_update_request.rb b/app/models/bulk_update_request.rb index b42539011..a189fe72a 100644 --- a/app/models/bulk_update_request.rb +++ b/app/models/bulk_update_request.rb @@ -31,9 +31,7 @@ class BulkUpdateRequest < ApplicationRecord end def search(params = {}) - q = super - - q = q.search_attributes(params, :script, :tags) + q = search_attributes(params, :id, :created_at, :updated_at, :script, :tags) q = q.text_attribute_matches(:script, params[:script_matches]) if params[:status].present? diff --git a/app/models/comment.rb b/app/models/comment.rb index 0c4fd1f8d..71f23b698 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -26,9 +26,7 @@ class Comment < ApplicationRecord module SearchMethods def search(params) - q = super - - q = q.search_attributes(params, :is_deleted, :is_sticky, :do_not_bump_post, :body, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_sticky, :do_not_bump_post, :body, :score) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index) case params[:order] diff --git a/app/models/comment_vote.rb b/app/models/comment_vote.rb index e44818f63..bb1fa3ca8 100644 --- a/app/models/comment_vote.rb +++ b/app/models/comment_vote.rb @@ -19,8 +19,7 @@ class CommentVote < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score) q.apply_default_order(params) end diff --git a/app/models/dmail.rb b/app/models/dmail.rb index e3885c236..a5c945f89 100644 --- a/app/models/dmail.rb +++ b/app/models/dmail.rb @@ -98,9 +98,7 @@ class Dmail < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :is_read, :is_deleted, :title, :body) + q = search_attributes(params, :id, :created_at, :updated_at, :is_read, :is_deleted, :title, :body) q = q.text_attribute_matches(:title, params[:title_matches]) q = q.text_attribute_matches(:body, params[:message_matches], index_column: :message_index) diff --git a/app/models/dtext_link.rb b/app/models/dtext_link.rb index ea4902d53..9be3cc6b3 100644 --- a/app/models/dtext_link.rb +++ b/app/models/dtext_link.rb @@ -30,8 +30,7 @@ class DtextLink < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :link_type, :link_target) + q = search_attributes(params, :id, :created_at, :updated_at, :link_type, :link_target) q.apply_default_order(params) end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 9d4dd6f22..ffe6175f1 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -38,9 +38,7 @@ class EmailAddress < ApplicationRecord end def self.search(params) - q = super - - q = q.search_attributes(params, :user, :address, :normalized_address, :is_verified, :is_deliverable) + q = search_attributes(params, :id, :created_at, :updated_at, :user, :address, :normalized_address, :is_verified, :is_deliverable) q = q.apply_default_order(params) q diff --git a/app/models/favorite.rb b/app/models/favorite.rb index a5488f3fd..f6ce651bd 100644 --- a/app/models/favorite.rb +++ b/app/models/favorite.rb @@ -12,8 +12,7 @@ class Favorite < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :post) + q = search_attributes(params, :id, :post) if params[:user_id].present? q = q.for_user(params[:user_id]) diff --git a/app/models/favorite_group.rb b/app/models/favorite_group.rb index e34ef706b..45040b79f 100644 --- a/app/models/favorite_group.rb +++ b/app/models/favorite_group.rb @@ -26,8 +26,7 @@ class FavoriteGroup < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :name, :is_public, :post_ids) + q = search_attributes(params, :id, :created_at, :updated_at, :name, :is_public, :post_ids) if params[:name_matches].present? q = q.name_matches(params[:name_matches]) diff --git a/app/models/forum_post.rb b/app/models/forum_post.rb index 2558a33ce..0d05876b2 100644 --- a/app/models/forum_post.rb +++ b/app/models/forum_post.rb @@ -41,8 +41,7 @@ class ForumPost < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :is_deleted, :body) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :body) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :text_index) if params[:linked_to].present? diff --git a/app/models/forum_post_vote.rb b/app/models/forum_post_vote.rb index 3ea592389..7e36cd879 100644 --- a/app/models/forum_post_vote.rb +++ b/app/models/forum_post_vote.rb @@ -19,8 +19,7 @@ class ForumPostVote < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score) q = q.forum_post_matches(params[:forum_post]) q.apply_default_order(params) end diff --git a/app/models/forum_topic.rb b/app/models/forum_topic.rb index f44a727d6..f70a02af6 100644 --- a/app/models/forum_topic.rb +++ b/app/models/forum_topic.rb @@ -86,8 +86,7 @@ class ForumTopic < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :is_sticky, :is_locked, :is_deleted, :category_id, :title, :response_count) + q = search_attributes(params, :id, :created_at, :updated_at, :is_sticky, :is_locked, :is_deleted, :category_id, :title, :response_count) q = q.text_attribute_matches(:title, params[:title_matches], index_column: :text_index) if params[:is_private].to_s.truthy? diff --git a/app/models/forum_topic_visit.rb b/app/models/forum_topic_visit.rb index 531f37f39..8fe1ac8ac 100644 --- a/app/models/forum_topic_visit.rb +++ b/app/models/forum_topic_visit.rb @@ -7,8 +7,7 @@ class ForumTopicVisit < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :user, :forum_topic_id, :last_read_at) + q = search_attributes(params, :id, :created_at, :updated_at, :user, :forum_topic_id, :last_read_at) q.apply_default_order(params) end end diff --git a/app/models/ip_address.rb b/app/models/ip_address.rb index 322de5167..0e286f7f4 100644 --- a/app/models/ip_address.rb +++ b/app/models/ip_address.rb @@ -12,8 +12,7 @@ class IpAddress < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :ip_addr) + q = search_attributes(params, :ip_addr) q.order(created_at: :desc) end diff --git a/app/models/ip_ban.rb b/app/models/ip_ban.rb index 76b3b7c05..1182bef54 100644 --- a/app/models/ip_ban.rb +++ b/app/models/ip_ban.rb @@ -25,8 +25,7 @@ class IpBan < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :reason) q = q.text_attribute_matches(:reason, params[:reason_matches]) if params[:ip_addr].present? diff --git a/app/models/mod_action.rb b/app/models/mod_action.rb index 87fbfda97..7d0b73615 100644 --- a/app/models/mod_action.rb +++ b/app/models/mod_action.rb @@ -61,9 +61,7 @@ class ModAction < ApplicationRecord end def self.search(params) - q = super - - q = q.search_attributes(params, :category, :description) + q = search_attributes(params, :id, :created_at, :updated_at, :category, :description) q = q.text_attribute_matches(:description, params[:description_matches]) q.apply_default_order(params) diff --git a/app/models/moderation_report.rb b/app/models/moderation_report.rb index 4fa1f77de..9916154cd 100644 --- a/app/models/moderation_report.rb +++ b/app/models/moderation_report.rb @@ -82,8 +82,7 @@ class ModerationReport < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :reason) q = q.text_attribute_matches(:reason, params[:reason_matches]) q.apply_default_order(params) diff --git a/app/models/note.rb b/app/models/note.rb index 3cb745da7..431161e5f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -14,9 +14,7 @@ class Note < ApplicationRecord module SearchMethods def search(params) - q = super - - q = q.search_attributes(params, :is_active, :x, :y, :width, :height, :body, :version) + q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index) q.apply_default_order(params) diff --git a/app/models/note_version.rb b/app/models/note_version.rb index d8c3aed07..310e0ef16 100644 --- a/app/models/note_version.rb +++ b/app/models/note_version.rb @@ -4,9 +4,7 @@ class NoteVersion < ApplicationRecord belongs_to_updater :counter_cache => "note_update_count" def self.search(params) - q = super - - q = q.search_attributes(params, :is_active, :x, :y, :width, :height, :body, :version) + q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version) q = q.text_attribute_matches(:body, params[:body_matches]) q.apply_default_order(params) diff --git a/app/models/pixiv_ugoira_frame_data.rb b/app/models/pixiv_ugoira_frame_data.rb index 92c1eba2b..8c35a9387 100644 --- a/app/models/pixiv_ugoira_frame_data.rb +++ b/app/models/pixiv_ugoira_frame_data.rb @@ -13,8 +13,7 @@ class PixivUgoiraFrameData < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :data, :content_type) + q = search_attributes(params, :id, :data, :content_type) q.apply_default_order(params) end diff --git a/app/models/pool.rb b/app/models/pool.rb index 4c3f59e9d..547c8e295 100644 --- a/app/models/pool.rb +++ b/app/models/pool.rb @@ -36,9 +36,7 @@ class Pool < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :is_deleted, :name, :description, :post_ids) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :name, :description, :post_ids) q = q.text_attribute_matches(:description, params[:description_matches]) if params[:post_tags_match] diff --git a/app/models/pool_version.rb b/app/models/pool_version.rb index 1bf6f3cb6..671f7c982 100644 --- a/app/models/pool_version.rb +++ b/app/models/pool_version.rb @@ -32,8 +32,7 @@ class PoolVersion < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :pool_id, :post_ids, :added_post_ids, :removed_post_ids, :updater_id, :description, :description_changed, :name, :name_changed, :version, :is_active, :is_deleted, :category) + q = search_attributes(params, :id, :created_at, :updated_at, :pool_id, :post_ids, :added_post_ids, :removed_post_ids, :updater_id, :description, :description_changed, :name, :name_changed, :version, :is_active, :is_deleted, :category) if params[:post_id] q = q.for_post_id(params[:post_id].to_i) diff --git a/app/models/post.rb b/app/models/post.rb index 6f54da31c..e4f1b5afe 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1289,14 +1289,14 @@ class Post < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes( + q = search_attributes( params, - :rating, :source, :pixiv_id, :fav_count, :score, :up_score, :down_score, :md5, :file_ext, - :file_size, :image_width, :image_height, :tag_count, :has_children, :has_active_children, - :is_note_locked, :is_rating_locked, :is_status_locked, :is_pending, :is_flagged, :is_deleted, - :is_banned, :last_comment_bumped_at, :last_commented_at, :last_noted_at + :id, :created_at, :updated_at, :rating, :source, :pixiv_id, :fav_count, + :score, :up_score, :down_score, :md5, :file_ext, :file_size, :image_width, + :image_height, :tag_count, :has_children, :has_active_children, + :is_note_locked, :is_rating_locked, :is_status_locked, :is_pending, + :is_flagged, :is_deleted, :is_banned, :last_comment_bumped_at, + :last_commented_at, :last_noted_at, :uploader_ip_addr ) if params[:tags].present? diff --git a/app/models/post_appeal.rb b/app/models/post_appeal.rb index af54ce997..e8049e88c 100644 --- a/app/models/post_appeal.rb +++ b/app/models/post_appeal.rb @@ -17,8 +17,7 @@ class PostAppeal < ApplicationRecord module SearchMethods def search(params) - q = super - q = q.search_attributes(params, :reason, :status) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status) q = q.text_attribute_matches(:reason, params[:reason_matches]) q.apply_default_order(params) diff --git a/app/models/post_approval.rb b/app/models/post_approval.rb index 599cd1ba9..127d49958 100644 --- a/app/models/post_approval.rb +++ b/app/models/post_approval.rb @@ -38,7 +38,7 @@ class PostApproval < ApplicationRecord end def self.search(params) - q = super + q = search_attributes(params, :id, :created_at, :updated_at, :post, :user) q.apply_default_order(params) end diff --git a/app/models/post_disapproval.rb b/app/models/post_disapproval.rb index 08fac4921..f08d318c9 100644 --- a/app/models/post_disapproval.rb +++ b/app/models/post_disapproval.rb @@ -21,9 +21,7 @@ class PostDisapproval < ApplicationRecord concerning :SearchMethods do class_methods do def search(params) - q = super - - q = q.search_attributes(params, :message, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :message, :reason) q = q.text_attribute_matches(:message, params[:message_matches]) q = q.with_message if params[:has_message].to_s.truthy? diff --git a/app/models/post_flag.rb b/app/models/post_flag.rb index 24493ba2f..3254f073c 100644 --- a/app/models/post_flag.rb +++ b/app/models/post_flag.rb @@ -56,9 +56,7 @@ class PostFlag < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :reason, :status) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status) q = q.text_attribute_matches(:reason, params[:reason_matches]) if params[:creator_id].present? diff --git a/app/models/post_replacement.rb b/app/models/post_replacement.rb index 35dd266a6..04e80a78d 100644 --- a/app/models/post_replacement.rb +++ b/app/models/post_replacement.rb @@ -21,8 +21,7 @@ class PostReplacement < ApplicationRecord concerning :Search do class_methods do def search(params = {}) - q = super - q = q.search_attributes(params, :md5, :md5_was, :file_ext, :file_ext_was, :original_url, :replacement_url) + q = search_attributes(params, :id, :created_at, :updated_at, :md5, :md5_was, :file_ext, :file_ext_was, :original_url, :replacement_url) q.apply_default_order(params) end end diff --git a/app/models/post_version.rb b/app/models/post_version.rb index 59bd38b83..3cb63ecb9 100644 --- a/app/models/post_version.rb +++ b/app/models/post_version.rb @@ -38,8 +38,7 @@ class PostVersion < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :updater_id, :post_id, :tags, :added_tags, :removed_tags, :rating, :rating_changed, :parent_id, :parent_changed, :source, :source_changed, :version) + q = search_attributes(params, :id, :updated_at, :updater_id, :post_id, :tags, :added_tags, :removed_tags, :rating, :rating_changed, :parent_id, :parent_changed, :source, :source_changed, :version) if params[:changed_tags] q = q.changed_tags_include_all(params[:changed_tags].scan(/[^[:space:]]+/)) diff --git a/app/models/post_vote.rb b/app/models/post_vote.rb index fdc9aa896..096b123df 100644 --- a/app/models/post_vote.rb +++ b/app/models/post_vote.rb @@ -19,8 +19,7 @@ class PostVote < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score) q.apply_default_order(params) end diff --git a/app/models/saved_search.rb b/app/models/saved_search.rb index 8ab9aba52..e3dad2dc0 100644 --- a/app/models/saved_search.rb +++ b/app/models/saved_search.rb @@ -113,8 +113,7 @@ class SavedSearch < ApplicationRecord concerning :Search do class_methods do def search(params) - q = super - q = q.search_attributes(params, :query) + q = search_attributes(params, :id, :created_at, :updated_at, :query) if params[:label] q = q.labeled(params[:label]) diff --git a/app/models/tag.rb b/app/models/tag.rb index 3c3a3bf65..86fd3fb1e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -271,9 +271,7 @@ class Tag < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :is_locked, :category, :post_count, :name) + q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :category, :post_count, :name) if params[:fuzzy_name_matches].present? q = q.fuzzy_name_matches(params[:fuzzy_name_matches]) diff --git a/app/models/tag_relationship.rb b/app/models/tag_relationship.rb index 341f3182d..1d303b6e7 100644 --- a/app/models/tag_relationship.rb +++ b/app/models/tag_relationship.rb @@ -66,8 +66,7 @@ class TagRelationship < ApplicationRecord end def search(params) - q = super - q = q.search_attributes(params, :antecedent_name, :consequent_name) + q = search_attributes(params, :id, :created_at, :updated_at, :antecedent_name, :consequent_name) if params[:name_matches].present? q = q.name_matches(params[:name_matches]) diff --git a/app/models/upload.rb b/app/models/upload.rb index 04c61a078..383c59a65 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -182,9 +182,7 @@ class Upload < ApplicationRecord end def self.search(params) - q = super - - q = q.search_attributes(params, :source, :rating, :parent_id, :server, :md5, :server, :file_ext, :file_size, :image_width, :image_height, :referer_url) + q = search_attributes(params, :id, :created_at, :updated_at, :source, :rating, :parent_id, :server, :md5, :server, :file_ext, :file_size, :image_width, :image_height, :referer_url) if params[:source_matches].present? q = q.where_like(:source, params[:source_matches]) diff --git a/app/models/user.rb b/app/models/user.rb index 7ac2d8aea..ee0603a16 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -560,12 +560,10 @@ class User < ApplicationRecord module SearchMethods def search(params) - q = super - params = params.dup params[:name_matches] = params.delete(:name) if params[:name].present? - q = q.search_attributes(params, :name, :level, :post_upload_count, :post_update_count, :note_update_count, :favorite_count) + q = search_attributes(params, :id, :created_at, :updated_at, :name, :level, :post_upload_count, :post_update_count, :note_update_count, :favorite_count) if params[:name_matches].present? q = q.where_ilike(:name, normalize_name(params[:name_matches])) diff --git a/app/models/user_feedback.rb b/app/models/user_feedback.rb index eaf24fcf4..f52d0d856 100644 --- a/app/models/user_feedback.rb +++ b/app/models/user_feedback.rb @@ -30,9 +30,7 @@ class UserFeedback < ApplicationRecord end def search(params) - q = super - - q = q.search_attributes(params, :category, :body, :is_deleted) + q = search_attributes(params, :id, :created_at, :updated_at, :category, :body, :is_deleted) q = q.text_attribute_matches(:body, params[:body_matches]) q.apply_default_order(params) diff --git a/app/models/user_name_change_request.rb b/app/models/user_name_change_request.rb index c3261e000..9b6b69e2b 100644 --- a/app/models/user_name_change_request.rb +++ b/app/models/user_name_change_request.rb @@ -19,8 +19,7 @@ class UserNameChangeRequest < ApplicationRecord end def self.search(params) - q = super - q = q.search_attributes(params, :user, :original_name, :desired_name) + q = search_attributes(params, :id, :created_at, :updated_at, :user, :original_name, :desired_name) q.apply_default_order(params) end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 603ddcecd..f92a0936e 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -65,9 +65,7 @@ class WikiPage < ApplicationRecord end def search(params = {}) - q = super - - q = q.search_attributes(params, :is_locked, :is_deleted, :body, :title, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :is_deleted, :body, :title, :other_names) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index, ts_config: "danbooru") if params[:title_normalize].present? diff --git a/app/models/wiki_page_version.rb b/app/models/wiki_page_version.rb index b0687b191..264474951 100644 --- a/app/models/wiki_page_version.rb +++ b/app/models/wiki_page_version.rb @@ -7,9 +7,7 @@ class WikiPageVersion < ApplicationRecord module SearchMethods def search(params) - q = super - - q = q.search_attributes(params, :title, :body, :other_names, :is_locked, :is_deleted) + q = search_attributes(params, :id, :created_at, :updated_at, :title, :body, :other_names, :is_locked, :is_deleted) q = q.text_attribute_matches(:title, params[:title_matches]) q = q.text_attribute_matches(:body, params[:body_matches]) From ee4516f5fe6e665e98d64a0bdde48208a59d4afb Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 16 Dec 2020 22:43:18 -0600 Subject: [PATCH 042/132] searchable: refactor searchable_includes. Pass searchable associations directly to search_attributes instead of defining them separately in searchable_includes. --- app/logical/concerns/searchable.rb | 1 - app/models/application_record.rb | 4 ---- app/models/artist.rb | 6 +----- app/models/artist_commentary.rb | 6 +----- app/models/artist_commentary_version.rb | 6 +----- app/models/artist_url.rb | 6 +----- app/models/artist_version.rb | 6 +----- app/models/ban.rb | 6 +----- app/models/bulk_update_request.rb | 6 +----- app/models/comment.rb | 6 +----- app/models/comment_vote.rb | 6 +----- app/models/dmail.rb | 6 +----- app/models/dtext_link.rb | 6 +----- app/models/favorite_group.rb | 6 +----- app/models/forum_post.rb | 6 +----- app/models/forum_post_vote.rb | 6 +----- app/models/forum_topic.rb | 6 +----- app/models/ip_address.rb | 6 +----- app/models/ip_ban.rb | 6 +----- app/models/mod_action.rb | 6 +----- app/models/moderation_report.rb | 6 +----- app/models/note.rb | 6 +----- app/models/note_version.rb | 6 +----- app/models/pixiv_ugoira_frame_data.rb | 6 +----- app/models/post.rb | 9 ++++----- app/models/post_appeal.rb | 6 +----- app/models/post_approval.rb | 6 +----- app/models/post_disapproval.rb | 6 +----- app/models/post_flag.rb | 6 +----- app/models/post_replacement.rb | 6 +----- app/models/post_vote.rb | 6 +----- app/models/tag.rb | 6 +----- app/models/tag_relationship.rb | 6 +----- app/models/upload.rb | 6 +----- app/models/user.rb | 13 ++++++++----- app/models/user_feedback.rb | 6 +----- app/models/wiki_page.rb | 6 +----- app/models/wiki_page_version.rb | 6 +----- 38 files changed, 46 insertions(+), 185 deletions(-) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index 321d89a81..cac920cc8 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -155,7 +155,6 @@ module Searchable indifferent_params = params.try(:with_indifferent_access) || params.try(:to_unsafe_h) raise ArgumentError, "unable to process params" if indifferent_params.nil? - attributes += searchable_includes attributes.reduce(all) do |relation, attribute| relation.search_attribute(attribute, indifferent_params, CurrentUser.user) end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index c65ae40d1..e162b39dd 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -93,10 +93,6 @@ class ApplicationRecord < ActiveRecord::Base concerning :SearchMethods do class_methods do - def searchable_includes - [] - end - def model_restriction(table) table.project(1) end diff --git a/app/models/artist.rb b/app/models/artist.rb index 9ee09c407..56f1baee8 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -250,7 +250,7 @@ class Artist < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :other_names, :urls, :wiki_page, :tag_alias, :tag) if params[:any_other_name_like] q = q.any_other_name_like(params[:any_other_name_like]) @@ -295,10 +295,6 @@ class Artist < ApplicationRecord super.where(table[:is_deleted].eq(false)) end - def self.searchable_includes - [:urls, :wiki_page, :tag_alias, :tag] - end - def self.available_includes [:members, :urls, :wiki_page, :tag_alias, :tag] end diff --git a/app/models/artist_commentary.rb b/app/models/artist_commentary.rb index 1ea297c62..d1b5bcee6 100644 --- a/app/models/artist_commentary.rb +++ b/app/models/artist_commentary.rb @@ -31,7 +31,7 @@ class ArtistCommentary < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description) + q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description, :post) if params[:text_matches].present? q = q.text_matches(params[:text_matches]) @@ -144,10 +144,6 @@ class ArtistCommentary < ApplicationRecord extend SearchMethods include VersionMethods - def self.searchable_includes - [:post] - end - def self.available_includes [:post] end diff --git a/app/models/artist_commentary_version.rb b/app/models/artist_commentary_version.rb index c9fcb9a49..0b4c00436 100644 --- a/app/models/artist_commentary_version.rb +++ b/app/models/artist_commentary_version.rb @@ -12,7 +12,7 @@ class ArtistCommentaryVersion < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description) + q = search_attributes(params, :id, :created_at, :updated_at, :original_title, :original_description, :translated_title, :translated_description, :post, :updater) if params[:text_matches].present? q = q.text_matches(params[:text_matches]) @@ -55,10 +55,6 @@ class ArtistCommentaryVersion < ApplicationRecord self[field].strip.empty? && (previous.nil? || previous[field].strip.empty?) end - def self.searchable_includes - [:post, :updater] - end - def self.available_includes [:post, :updater] end diff --git a/app/models/artist_url.rb b/app/models/artist_url.rb index bd17c7358..fa9b359fb 100644 --- a/app/models/artist_url.rb +++ b/app/models/artist_url.rb @@ -40,7 +40,7 @@ class ArtistUrl < ApplicationRecord end def self.search(params = {}) - q = search_attributes(params, :id, :created_at, :updated_at, :url, :normalized_url, :is_active) + q = search_attributes(params, :id, :created_at, :updated_at, :url, :normalized_url, :is_active, :artist) q = q.url_matches(params[:url_matches]) q = q.normalized_url_matches(params[:normalized_url_matches]) @@ -126,10 +126,6 @@ class ArtistUrl < ApplicationRecord errors.add(:url, "'#{uri}' is malformed: #{error}") end - def self.searchable_includes - [:artist] - end - def self.available_includes [:artist] end diff --git a/app/models/artist_version.rb b/app/models/artist_version.rb index c7af5690a..a743c43ed 100644 --- a/app/models/artist_version.rb +++ b/app/models/artist_version.rb @@ -7,7 +7,7 @@ class ArtistVersion < ApplicationRecord module SearchMethods def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :urls, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :urls, :other_names, :updater, :artist) q = q.text_attribute_matches(:name, params[:name_matches]) q = q.text_attribute_matches(:group_name, params[:group_name_matches]) @@ -103,10 +103,6 @@ class ArtistVersion < ApplicationRecord end end - def self.searchable_includes - [:updater, :artist] - end - def self.available_includes [:updater, :artist] end diff --git a/app/models/ban.rb b/app/models/ban.rb index 6417ba739..c641b4d5a 100644 --- a/app/models/ban.rb +++ b/app/models/ban.rb @@ -20,7 +20,7 @@ class Ban < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :expires_at, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :expires_at, :reason, :user, :banner) q = q.text_attribute_matches(:reason, params[:reason_matches]) q = q.expired if params[:expired].to_s.truthy? @@ -87,10 +87,6 @@ class Ban < ApplicationRecord ModAction.log(%{Unbanned <@#{user_name}>}, :user_unban) end - def self.searchable_includes - [:user, :banner] - end - def self.available_includes [:user, :banner] end diff --git a/app/models/bulk_update_request.rb b/app/models/bulk_update_request.rb index a189fe72a..d7afa226d 100644 --- a/app/models/bulk_update_request.rb +++ b/app/models/bulk_update_request.rb @@ -31,7 +31,7 @@ class BulkUpdateRequest < ApplicationRecord end def search(params = {}) - q = search_attributes(params, :id, :created_at, :updated_at, :script, :tags) + q = search_attributes(params, :id, :created_at, :updated_at, :script, :tags, :user, :forum_topic, :forum_post, :approver) q = q.text_attribute_matches(:script, params[:script_matches]) if params[:status].present? @@ -126,10 +126,6 @@ class BulkUpdateRequest < ApplicationRecord status == "rejected" end - def self.searchable_includes - [:user, :forum_topic, :forum_post, :approver] - end - def self.available_includes [:user, :forum_topic, :forum_post, :approver] end diff --git a/app/models/comment.rb b/app/models/comment.rb index 71f23b698..2207706ef 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -26,7 +26,7 @@ class Comment < ApplicationRecord module SearchMethods def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_sticky, :do_not_bump_post, :body, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_sticky, :do_not_bump_post, :body, :score, :post, :creator, :updater) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index) case params[:order] @@ -137,10 +137,6 @@ class Comment < ApplicationRecord DText.quote(body, creator.name) end - def self.searchable_includes - [:post, :creator, :updater] - end - def self.available_includes [:post, :creator, :updater] end diff --git a/app/models/comment_vote.rb b/app/models/comment_vote.rb index bb1fa3ca8..b87642d10 100644 --- a/app/models/comment_vote.rb +++ b/app/models/comment_vote.rb @@ -19,7 +19,7 @@ class CommentVote < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score, :comment, :user) q.apply_default_order(params) end @@ -37,10 +37,6 @@ class CommentVote < ApplicationRecord score == -1 end - def self.searchable_includes - [:comment, :user] - end - def self.available_includes [:comment, :user] end diff --git a/app/models/dmail.rb b/app/models/dmail.rb index a5c945f89..63094bff5 100644 --- a/app/models/dmail.rb +++ b/app/models/dmail.rb @@ -98,7 +98,7 @@ class Dmail < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_read, :is_deleted, :title, :body) + q = search_attributes(params, :id, :created_at, :updated_at, :is_read, :is_deleted, :title, :body, :to, :from) q = q.text_attribute_matches(:title, params[:title_matches]) q = q.text_attribute_matches(:body, params[:message_matches], index_column: :message_index) @@ -180,10 +180,6 @@ class Dmail < ApplicationRecord key ? "dmail ##{id}/#{self.key}" : "dmail ##{id}" end - def self.searchable_includes - [:to, :from] - end - def self.available_includes [:owner, :to, :from] end diff --git a/app/models/dtext_link.rb b/app/models/dtext_link.rb index 9be3cc6b3..3d3f8d62b 100644 --- a/app/models/dtext_link.rb +++ b/app/models/dtext_link.rb @@ -30,7 +30,7 @@ class DtextLink < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :link_type, :link_target) + q = search_attributes(params, :id, :created_at, :updated_at, :link_type, :link_target, :model, :linked_wiki, :linked_tag) q.apply_default_order(params) end @@ -48,10 +48,6 @@ class DtextLink < ApplicationRecord where(link_type: :wiki_link) end - def self.searchable_includes - [:model, :linked_wiki, :linked_tag] - end - def self.available_includes [:model, :linked_wiki, :linked_tag] end diff --git a/app/models/favorite_group.rb b/app/models/favorite_group.rb index 45040b79f..5a0c07190 100644 --- a/app/models/favorite_group.rb +++ b/app/models/favorite_group.rb @@ -26,7 +26,7 @@ class FavoriteGroup < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :name, :is_public, :post_ids) + q = search_attributes(params, :id, :created_at, :updated_at, :name, :is_public, :post_ids, :creator) if params[:name_matches].present? q = q.name_matches(params[:name_matches]) @@ -163,10 +163,6 @@ class FavoriteGroup < ApplicationRecord post_ids.include?(post_id) end - def self.searchable_includes - [:creator] - end - def self.available_includes [:creator] end diff --git a/app/models/forum_post.rb b/app/models/forum_post.rb index 0d05876b2..c8aad7962 100644 --- a/app/models/forum_post.rb +++ b/app/models/forum_post.rb @@ -41,7 +41,7 @@ class ForumPost < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :body) + q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :body, :creator, :updater, :topic, :dtext_links, :votes, :tag_alias, :tag_implication, :bulk_update_request) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :text_index) if params[:linked_to].present? @@ -163,10 +163,6 @@ class ForumPost < ApplicationRecord "forum ##{id}" end - def self.searchable_includes - [:creator, :updater, :topic, :dtext_links, :votes, :tag_alias, :tag_implication, :bulk_update_request] - end - def self.available_includes [:creator, :updater, :topic, :dtext_links, :votes, :tag_alias, :tag_implication, :bulk_update_request] end diff --git a/app/models/forum_post_vote.rb b/app/models/forum_post_vote.rb index 7e36cd879..6a2ac7926 100644 --- a/app/models/forum_post_vote.rb +++ b/app/models/forum_post_vote.rb @@ -19,7 +19,7 @@ class ForumPostVote < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score, :creator, :forum_post) q = q.forum_post_matches(params[:forum_post]) q.apply_default_order(params) end @@ -58,10 +58,6 @@ class ForumPostVote < ApplicationRecord end end - def self.searchable_includes - [:creator, :forum_post] - end - def self.available_includes [:creator, :forum_post] end diff --git a/app/models/forum_topic.rb b/app/models/forum_topic.rb index f70a02af6..0ab60d042 100644 --- a/app/models/forum_topic.rb +++ b/app/models/forum_topic.rb @@ -86,7 +86,7 @@ class ForumTopic < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_sticky, :is_locked, :is_deleted, :category_id, :title, :response_count) + q = search_attributes(params, :id, :created_at, :updated_at, :is_sticky, :is_locked, :is_deleted, :category_id, :title, :response_count, :creator, :updater, :forum_posts, :bulk_update_requests, :tag_aliases, :tag_implications) q = q.text_attribute_matches(:title, params[:title_matches], index_column: :text_index) if params[:is_private].to_s.truthy? @@ -189,10 +189,6 @@ class ForumTopic < ApplicationRecord title.gsub(/\A\[APPROVED\]|\[REJECTED\]/, "") end - def self.searchable_includes - [:creator, :updater, :forum_posts, :bulk_update_requests, :tag_aliases, :tag_implications] - end - def self.available_includes [:creator, :updater, :original_post] end diff --git a/app/models/ip_address.rb b/app/models/ip_address.rb index 0e286f7f4..77f44b3ff 100644 --- a/app/models/ip_address.rb +++ b/app/models/ip_address.rb @@ -12,7 +12,7 @@ class IpAddress < ApplicationRecord end def self.search(params) - q = search_attributes(params, :ip_addr) + q = search_attributes(params, :ip_addr, :user, :model) q.order(created_at: :desc) end @@ -49,10 +49,6 @@ class IpAddress < ApplicationRecord true end - def self.searchable_includes - [:user, :model] - end - def self.available_includes [:user, :model] end diff --git a/app/models/ip_ban.rb b/app/models/ip_ban.rb index 1182bef54..223539f43 100644 --- a/app/models/ip_ban.rb +++ b/app/models/ip_ban.rb @@ -25,7 +25,7 @@ class IpBan < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :creator) q = q.text_attribute_matches(:reason, params[:reason_matches]) if params[:ip_addr].present? @@ -77,10 +77,6 @@ class IpBan < ApplicationRecord super(ip_addr.strip) end - def self.searchable_includes - [:creator] - end - def self.available_includes [:creator] end diff --git a/app/models/mod_action.rb b/app/models/mod_action.rb index 7d0b73615..437d3efea 100644 --- a/app/models/mod_action.rb +++ b/app/models/mod_action.rb @@ -61,7 +61,7 @@ class ModAction < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :category, :description) + q = search_attributes(params, :id, :created_at, :updated_at, :category, :description, :creator) q = q.text_attribute_matches(:description, params[:description_matches]) q.apply_default_order(params) @@ -75,10 +75,6 @@ class ModAction < ApplicationRecord create(creator: user, description: desc, category: categories[cat]) end - def self.searchable_includes - [:creator] - end - def self.available_includes [:creator] end diff --git a/app/models/moderation_report.rb b/app/models/moderation_report.rb index 9916154cd..20874e2d1 100644 --- a/app/models/moderation_report.rb +++ b/app/models/moderation_report.rb @@ -82,16 +82,12 @@ class ModerationReport < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :creator, :model) q = q.text_attribute_matches(:reason, params[:reason_matches]) q.apply_default_order(params) end - def self.searchable_includes - [:creator, :model] - end - def self.available_includes [:creator, :model] end diff --git a/app/models/note.rb b/app/models/note.rb index 431161e5f..f2cc46ed3 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -14,7 +14,7 @@ class Note < ApplicationRecord module SearchMethods def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version) + q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version, :post) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index) q.apply_default_order(params) @@ -127,10 +127,6 @@ class Note < ApplicationRecord new_note.save end - def self.searchable_includes - [:post] - end - def self.available_includes [:post] end diff --git a/app/models/note_version.rb b/app/models/note_version.rb index 310e0ef16..2b973650f 100644 --- a/app/models/note_version.rb +++ b/app/models/note_version.rb @@ -4,7 +4,7 @@ class NoteVersion < ApplicationRecord belongs_to_updater :counter_cache => "note_update_count" def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version) + q = search_attributes(params, :id, :created_at, :updated_at, :is_active, :x, :y, :width, :height, :body, :version, :updater, :note, :post) q = q.text_attribute_matches(:body, params[:body_matches]) q.apply_default_order(params) @@ -69,10 +69,6 @@ class NoteVersion < ApplicationRecord end end - def self.searchable_includes - [:updater, :note, :post] - end - def self.available_includes [:updater, :note, :post] end diff --git a/app/models/pixiv_ugoira_frame_data.rb b/app/models/pixiv_ugoira_frame_data.rb index 8c35a9387..bbb5a5498 100644 --- a/app/models/pixiv_ugoira_frame_data.rb +++ b/app/models/pixiv_ugoira_frame_data.rb @@ -4,16 +4,12 @@ class PixivUgoiraFrameData < ApplicationRecord serialize :data before_validation :normalize_data, on: :create - def self.searchable_includes - [:post] - end - def self.available_includes [:post] end def self.search(params) - q = search_attributes(params, :id, :data, :content_type) + q = search_attributes(params, :id, :data, :content_type, :post) q.apply_default_order(params) end diff --git a/app/models/post.rb b/app/models/post.rb index e4f1b5afe..9fd75b7ad 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1296,7 +1296,10 @@ class Post < ApplicationRecord :image_height, :tag_count, :has_children, :has_active_children, :is_note_locked, :is_rating_locked, :is_status_locked, :is_pending, :is_flagged, :is_deleted, :is_banned, :last_comment_bumped_at, - :last_commented_at, :last_noted_at, :uploader_ip_addr + :last_commented_at, :last_noted_at, :uploader_ip_addr, + :uploader, :updater, :approver, :parent, :upload, :artist_commentary, + :flags, :appeals, :notes, :comments, :children, :approvals, + :replacements, :pixiv_ugoira_frame_data ) if params[:tags].present? @@ -1499,10 +1502,6 @@ class Post < ApplicationRecord super.where(table[:is_pending].eq(false)).where(table[:is_flagged].eq(false)).where(table[:is_deleted].eq(false)) end - def self.searchable_includes - [:uploader, :updater, :approver, :parent, :upload, :artist_commentary, :flags, :appeals, :notes, :comments, :children, :approvals, :replacements, :pixiv_ugoira_frame_data] - end - def self.available_includes [:uploader, :updater, :approver, :parent, :upload, :artist_commentary, :flags, :appeals, :notes, :comments, :children, :approvals, :replacements, :pixiv_ugoira_frame_data] end diff --git a/app/models/post_appeal.rb b/app/models/post_appeal.rb index e8049e88c..e7ce3d069 100644 --- a/app/models/post_appeal.rb +++ b/app/models/post_appeal.rb @@ -17,7 +17,7 @@ class PostAppeal < ApplicationRecord module SearchMethods def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status, :creator, :post) q = q.text_attribute_matches(:reason, params[:reason_matches]) q.apply_default_order(params) @@ -34,10 +34,6 @@ class PostAppeal < ApplicationRecord errors.add(:post, "cannot be appealed") if post.is_status_locked? || !post.is_appealable? end - def self.searchable_includes - [:creator, :post] - end - def self.available_includes [:creator, :post] end diff --git a/app/models/post_approval.rb b/app/models/post_approval.rb index 127d49958..1b95b619c 100644 --- a/app/models/post_approval.rb +++ b/app/models/post_approval.rb @@ -38,14 +38,10 @@ class PostApproval < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :post, :user) + q = search_attributes(params, :id, :created_at, :updated_at, :user, :post) q.apply_default_order(params) end - def self.searchable_includes - [:user, :post] - end - def self.available_includes [:user, :post] end diff --git a/app/models/post_disapproval.rb b/app/models/post_disapproval.rb index f08d318c9..eccf6f41c 100644 --- a/app/models/post_disapproval.rb +++ b/app/models/post_disapproval.rb @@ -21,7 +21,7 @@ class PostDisapproval < ApplicationRecord concerning :SearchMethods do class_methods do def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :message, :reason) + q = search_attributes(params, :id, :created_at, :updated_at, :message, :reason, :user, :post) q = q.text_attribute_matches(:message, params[:message_matches]) q = q.with_message if params[:has_message].to_s.truthy? @@ -39,10 +39,6 @@ class PostDisapproval < ApplicationRecord end end - def self.searchable_includes - [:user, :post] - end - def self.available_includes [:user, :post] end diff --git a/app/models/post_flag.rb b/app/models/post_flag.rb index 3254f073c..647d852ed 100644 --- a/app/models/post_flag.rb +++ b/app/models/post_flag.rb @@ -56,7 +56,7 @@ class PostFlag < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status) + q = search_attributes(params, :id, :created_at, :updated_at, :reason, :status, :post) q = q.text_attribute_matches(:reason, params[:reason_matches]) if params[:creator_id].present? @@ -111,10 +111,6 @@ class PostFlag < ApplicationRecord post.uploader_id end - def self.searchable_includes - [:post] - end - def self.available_includes [:post] end diff --git a/app/models/post_replacement.rb b/app/models/post_replacement.rb index 04e80a78d..ae5a23174 100644 --- a/app/models/post_replacement.rb +++ b/app/models/post_replacement.rb @@ -21,7 +21,7 @@ class PostReplacement < ApplicationRecord concerning :Search do class_methods do def search(params = {}) - q = search_attributes(params, :id, :created_at, :updated_at, :md5, :md5_was, :file_ext, :file_ext_was, :original_url, :replacement_url) + q = search_attributes(params, :id, :created_at, :updated_at, :md5, :md5_was, :file_ext, :file_ext_was, :original_url, :replacement_url, :creator, :post) q.apply_default_order(params) end end @@ -38,10 +38,6 @@ class PostReplacement < ApplicationRecord tags.join(" ") end - def self.searchable_includes - [:creator, :post] - end - def self.available_includes [:creator, :post] end diff --git a/app/models/post_vote.rb b/app/models/post_vote.rb index 096b123df..ab8ea1090 100644 --- a/app/models/post_vote.rb +++ b/app/models/post_vote.rb @@ -19,7 +19,7 @@ class PostVote < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :score) + q = search_attributes(params, :id, :created_at, :updated_at, :score, :user, :post) q.apply_default_order(params) end @@ -49,10 +49,6 @@ class PostVote < ApplicationRecord end end - def self.searchable_includes - [:user, :post] - end - def self.available_includes [:user, :post] end diff --git a/app/models/tag.rb b/app/models/tag.rb index 86fd3fb1e..27255bed9 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -271,7 +271,7 @@ class Tag < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :category, :post_count, :name) + q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :category, :post_count, :name, :wiki_page, :artist, :antecedent_alias, :consequent_aliases, :antecedent_implications, :consequent_implications, :dtext_links) if params[:fuzzy_name_matches].present? q = q.fuzzy_name_matches(params[:fuzzy_name_matches]) @@ -376,10 +376,6 @@ class Tag < ApplicationRecord super.where(table[:post_count].gt(0)) end - def self.searchable_includes - [:wiki_page, :artist, :antecedent_alias, :consequent_aliases, :antecedent_implications, :consequent_implications, :dtext_links] - end - def self.available_includes [:wiki_page, :artist, :antecedent_alias, :consequent_aliases, :antecedent_implications, :consequent_implications, :dtext_links] end diff --git a/app/models/tag_relationship.rb b/app/models/tag_relationship.rb index 1d303b6e7..28e6bacb1 100644 --- a/app/models/tag_relationship.rb +++ b/app/models/tag_relationship.rb @@ -66,7 +66,7 @@ class TagRelationship < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :antecedent_name, :consequent_name) + q = search_attributes(params, :id, :created_at, :updated_at, :antecedent_name, :consequent_name, :creator, :approver, :forum_post, :forum_topic, :antecedent_tag, :consequent_tag, :antecedent_wiki, :consequent_wiki) if params[:name_matches].present? q = q.name_matches(params[:name_matches]) @@ -125,10 +125,6 @@ class TagRelationship < ApplicationRecord super.where(table[:status].eq("active")) end - def self.searchable_includes - [:creator, :approver, :forum_post, :forum_topic, :antecedent_tag, :consequent_tag, :antecedent_wiki, :consequent_wiki] - end - def self.available_includes [:creator, :approver, :forum_post, :forum_topic, :antecedent_tag, :consequent_tag, :antecedent_wiki, :consequent_wiki] end diff --git a/app/models/upload.rb b/app/models/upload.rb index 383c59a65..6c2878076 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -182,7 +182,7 @@ class Upload < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :source, :rating, :parent_id, :server, :md5, :server, :file_ext, :file_size, :image_width, :image_height, :referer_url) + q = search_attributes(params, :id, :created_at, :updated_at, :source, :rating, :parent_id, :server, :md5, :server, :file_ext, :file_size, :image_width, :image_height, :referer_url, :uploader, :post) if params[:source_matches].present? q = q.where_like(:source, params[:source_matches]) @@ -223,10 +223,6 @@ class Upload < ApplicationRecord artist_commentary_title.present? || artist_commentary_desc.present? || translated_commentary_title.present? || translated_commentary_desc.present? end - def self.searchable_includes - [:uploader, :post] - end - def self.available_includes [:uploader, :post] end diff --git a/app/models/user.rb b/app/models/user.rb index ee0603a16..c56a19adb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -563,7 +563,14 @@ class User < ApplicationRecord params = params.dup params[:name_matches] = params.delete(:name) if params[:name].present? - q = search_attributes(params, :id, :created_at, :updated_at, :name, :level, :post_upload_count, :post_update_count, :note_update_count, :favorite_count) + q = search_attributes(params, + :id, :created_at, :updated_at, :name, :level, :post_upload_count, + :post_update_count, :note_update_count, :favorite_count, :posts, + :note_versions, :artist_commentary_versions, :post_appeals, + :post_approvals, :artist_versions, :comments, :wiki_page_versions, + :feedback, :forum_topics, :forum_posts, :forum_post_votes, + :tag_aliases, :tag_implications, :bans, :inviter + ) if params[:name_matches].present? q = q.where_ilike(:name, normalize_name(params[:name_matches])) @@ -631,10 +638,6 @@ class User < ApplicationRecord "<@#{name}>" end - def self.searchable_includes - [:posts, :note_versions, :artist_commentary_versions, :post_appeals, :post_approvals, :artist_versions, :comments, :wiki_page_versions, :feedback, :forum_topics, :forum_posts, :forum_post_votes, :tag_aliases, :tag_implications, :bans, :inviter] - end - def self.available_includes [:inviter] end diff --git a/app/models/user_feedback.rb b/app/models/user_feedback.rb index f52d0d856..cda52916c 100644 --- a/app/models/user_feedback.rb +++ b/app/models/user_feedback.rb @@ -30,7 +30,7 @@ class UserFeedback < ApplicationRecord end def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :category, :body, :is_deleted) + q = search_attributes(params, :id, :created_at, :updated_at, :category, :body, :is_deleted, :creator, :user) q = q.text_attribute_matches(:body, params[:body_matches]) q.apply_default_order(params) @@ -56,10 +56,6 @@ class UserFeedback < ApplicationRecord Dmail.create_automated(:to_id => user_id, :title => "Your user record has been updated", :body => body) end - def self.searchable_includes - [:creator, :user] - end - def self.available_includes [:creator, :user] end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index f92a0936e..3b0b28045 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -65,7 +65,7 @@ class WikiPage < ApplicationRecord end def search(params = {}) - q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :is_deleted, :body, :title, :other_names) + q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :is_deleted, :body, :title, :other_names, :tag, :artist, :dtext_links) q = q.text_attribute_matches(:body, params[:body_matches], index_column: :body_index, ts_config: "danbooru") if params[:title_normalize].present? @@ -246,10 +246,6 @@ class WikiPage < ApplicationRecord super.where(table[:is_deleted].eq(false)) end - def self.searchable_includes - [:tag, :artist, :dtext_links] - end - def self.available_includes [:tag, :artist, :dtext_links] end diff --git a/app/models/wiki_page_version.rb b/app/models/wiki_page_version.rb index 264474951..1f405d4f1 100644 --- a/app/models/wiki_page_version.rb +++ b/app/models/wiki_page_version.rb @@ -7,7 +7,7 @@ class WikiPageVersion < ApplicationRecord module SearchMethods def search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :title, :body, :other_names, :is_locked, :is_deleted) + q = search_attributes(params, :id, :created_at, :updated_at, :title, :body, :other_names, :is_locked, :is_deleted, :updater, :wiki_page, :artist, :tag) q = q.text_attribute_matches(:title, params[:title_matches]) q = q.text_attribute_matches(:body, params[:body_matches]) @@ -75,10 +75,6 @@ class WikiPageVersion < ApplicationRecord end end - def self.searchable_includes - [:updater, :wiki_page, :artist, :tag] - end - def self.available_includes [:updater, :wiki_page, :artist, :tag] end From 7a87225ac855f51679e000205e4685db9059d85e Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 03:05:27 -0600 Subject: [PATCH 043/132] Add basic server status page at /status. Lists versions of various dependencies plus some Postgres and Redis metrics. --- app/controllers/status_controller.rb | 8 ++ app/logical/server_status.rb | 109 ++++++++++++++++++++++ app/views/status/show.html.erb | 32 +++++++ config/routes.rb | 1 + test/functional/status_controller_test.rb | 20 ++++ 5 files changed, 170 insertions(+) create mode 100644 app/controllers/status_controller.rb create mode 100644 app/logical/server_status.rb create mode 100644 app/views/status/show.html.erb create mode 100644 test/functional/status_controller_test.rb diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb new file mode 100644 index 000000000..4cd509491 --- /dev/null +++ b/app/controllers/status_controller.rb @@ -0,0 +1,8 @@ +class StatusController < ApplicationController + respond_to :html, :json, :xml + + def show + @status = ServerStatus.new + respond_with(@status) + end +end diff --git a/app/logical/server_status.rb b/app/logical/server_status.rb new file mode 100644 index 000000000..197582bf7 --- /dev/null +++ b/app/logical/server_status.rb @@ -0,0 +1,109 @@ +class ServerStatus + extend Memoist + include ActiveModel::Serializers::JSON + include ActiveModel::Serializers::Xml + + def serializable_hash(*options) + { + status: { + hostname: hostname, + uptime: uptime, + loadavg: loadavg, + ruby_version: RUBY_VERSION, + distro_version: distro_version, + kernel_version: kernel_version, + libvips_version: libvips_version, + ffmpeg_version: ffmpeg_version, + mkvmerge_version: mkvmerge_version, + redis_version: redis_version, + postgres_version: postgres_version, + }, + postgres: { + connection_stats: postgres_connection_stats, + }, + redis: { + info: redis_info, + } + } + end + + concerning :InfoMethods do + def hostname + Socket.gethostname + end + + def uptime + seconds = File.read("/proc/uptime").split[0].to_f + "#{seconds.seconds.in_days.round} days" + end + + def loadavg + File.read("/proc/loadavg").chomp + end + + def kernel_version + File.read("/proc/version").chomp + end + + def distro_version + `source /etc/os-release; echo "$NAME $VERSION"`.chomp + end + + def libvips_version + Vips::LIBRARY_VERSION + end + + def ffmpeg_version + version = `ffmpeg -version` + version[/ffmpeg version ([0-9.]+)/, 1] + end + + def mkvmerge_version + `mkvmerge --version`.chomp + end + end + + concerning :RedisMethods do + def redis_info + return {} if Rails.cache.try(:redis).nil? + Rails.cache.redis.info + end + + def redis_used_memory + redis_info["used_memory_rss_human"] + end + + def redis_version + redis_info["redis_version"] + end + end + + concerning :PostgresMethods do + def postgres_version + ApplicationRecord.connection.select_value("SELECT version()") + end + + def postgres_active_connections + ApplicationRecord.connection.select_value("SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active'") + end + + def postgres_connection_stats + run_query("SELECT pid, state, query_start, state_change, xact_start, backend_start, backend_type FROM pg_stat_activity ORDER BY state, query_start DESC, backend_type") + end + + def run_query(query) + result = ApplicationRecord.connection.select_all(query) + serialize_result(result) + end + + def serialize_result(result) + result.rows.map do |row| + row.each_with_index.map do |col, i| + [result.columns[i], col] + end.to_h + end + end + end + + memoize :redis_info +end diff --git a/app/views/status/show.html.erb b/app/views/status/show.html.erb new file mode 100644 index 000000000..c0f138359 --- /dev/null +++ b/app/views/status/show.html.erb @@ -0,0 +1,32 @@ +
    +
    +

    Status

    + +
    + + Server: <%= @status.hostname %> + + <%= render "list", hash: @status.serializable_hash[:status] %> +
    + +

    Postgres

    + +
    + + <%= pluralize @status.postgres_active_connections, "active connection" %>. + + + <%= render "table", rows: @status.serializable_hash[:postgres][:connection_stats] %> +
    + +

    Redis

    + +
    + + <%= @status.redis_used_memory %> memory used. + + + <%= render "list", hash: @status.serializable_hash[:redis][:info] %> +
    +
    +
    diff --git a/config/routes.rb b/config/routes.rb index 11d7e7d28..9b916feaf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -227,6 +227,7 @@ Rails.application.routes.draw do get :sign_out, on: :collection end resource :source, :only => [:show] + resource :status, only: [:show], controller: "status" resources :tags do collection do get :autocomplete diff --git a/test/functional/status_controller_test.rb b/test/functional/status_controller_test.rb new file mode 100644 index 000000000..b05467262 --- /dev/null +++ b/test/functional/status_controller_test.rb @@ -0,0 +1,20 @@ +require 'test_helper' + +class StatusControllerTest < ActionDispatch::IntegrationTest + context "The status controller" do + should "work for a html response" do + get status_path + assert_response :success + end + + should "work for a json response" do + get status_path(format: :json) + assert_response :success + end + + should "work for an xml response" do + get status_path(format: :json) + assert_response :success + end + end +end From 1809f67b2b621accbaa884a5179d6bfb5aa54101 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 13:17:55 -0600 Subject: [PATCH 044/132] tags: don't allow tags to begin with a '/'. Disallow tags from starting with a '/' character. This is so that tag abbreviations in autocomplete, which start with a '/', don't conflict with regular tags. Also disallow some other punctuation characters: `%{})]. Currently no tags start with these characters. This is to reserve other special characters in case we need them for other future syntax extensions. --- app/logical/tag_name_validator.rb | 8 ++------ test/unit/autocomplete_service_test.rb | 10 ---------- test/unit/tag_test.rb | 7 +++++++ 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/app/logical/tag_name_validator.rb b/app/logical/tag_name_validator.rb index 10c1d4b67..63690f9ec 100644 --- a/app/logical/tag_name_validator.rb +++ b/app/logical/tag_name_validator.rb @@ -7,12 +7,8 @@ class TagNameValidator < ActiveModel::EachValidator record.errors.add(attribute, "'#{value}' cannot contain asterisks ('*')") when /,/ record.errors.add(attribute, "'#{value}' cannot contain commas (',')") - when /\A~/ - record.errors.add(attribute, "'#{value}' cannot begin with a tilde ('~')") - when /\A-/ - record.errors.add(attribute, "'#{value}' cannot begin with a dash ('-')") - when /\A_/ - record.errors.add(attribute, "'#{value}' cannot begin with an underscore") + when /\A[-~_`%){}\]\/]/ + record.errors.add(attribute, "'#{value}' cannot begin with a '#{value[0]}'") when /_\z/ record.errors.add(attribute, "'#{value}' cannot end with an underscore") when /__/ diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index de2e547b3..1abe96f80 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -97,16 +97,6 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) end - - should "work for regular tags starting with a /" do - create(:tag, name: "jojo_pose", post_count: 100) - create(:tag, name: "/jp/", post_count: 50) - - assert_autocomplete_equals(%w[/jp/ jojo_pose], "/", :tag_query) - assert_autocomplete_equals(%w[/jp/ jojo_pose], "/j", :tag_query) - assert_autocomplete_equals(%w[/jp/ jojo_pose], "/jp", :tag_query) - assert_autocomplete_equals(%w[/jp/], "/jp/", :tag_query) - end end should "autocomplete tags from wiki and artist other names" do diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index ede77bb90..43a06af12 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -169,6 +169,13 @@ class TagTest < ActiveSupport::TestCase should_not allow_value("___").for(:name).on(:create) should_not allow_value("~foo").for(:name).on(:create) should_not allow_value("-foo").for(:name).on(:create) + should_not allow_value("/foo").for(:name).on(:create) + should_not allow_value("`foo").for(:name).on(:create) + should_not allow_value("%foo").for(:name).on(:create) + should_not allow_value(")foo").for(:name).on(:create) + should_not allow_value("{foo").for(:name).on(:create) + should_not allow_value("}foo").for(:name).on(:create) + should_not allow_value("]foo").for(:name).on(:create) should_not allow_value("_foo").for(:name).on(:create) should_not allow_value("foo_").for(:name).on(:create) should_not allow_value("foo__bar").for(:name).on(:create) From 991896c4eb8e791e30f482dabf5be15800dd646f Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 13:41:19 -0600 Subject: [PATCH 045/132] tags: don't allow tags more than 170 chars long. Limit tag length to 170 chars. 170 chars was chosen because it's longer than the longest active tag on Danbooru. Tag length is limited because in some contexts we can't deal with excessively long tags. Tag autocorrect for example uses the levenshtein function in Postgres, which can't handle strings more than 255 chars long. --- app/logical/tag_name_validator.rb | 8 +++++++- test/unit/tag_test.rb | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/logical/tag_name_validator.rb b/app/logical/tag_name_validator.rb index 63690f9ec..029a8c5c0 100644 --- a/app/logical/tag_name_validator.rb +++ b/app/logical/tag_name_validator.rb @@ -1,6 +1,12 @@ class TagNameValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - case Tag.normalize_name(value) + value = Tag.normalize_name(value) + + if value.size > 170 + record.errors.add(attribute, "'#{value}' cannot be more than 255 characters long") + end + + case value when /\A_*\z/ record.errors.add(attribute, "'#{value}' cannot be blank") when /\*/ diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index 43a06af12..0c4bd3dce 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -185,6 +185,7 @@ class TagTest < ActiveSupport::TestCase should_not allow_value("café").for(:name).on(:create) should_not allow_value("東方").for(:name).on(:create) should_not allow_value("FAV:blah").for(:name).on(:create) + should_not allow_value("X"*171).for(:name).on(:create) metatags = PostQueryBuilder::METATAGS + TagCategory.mapping.keys metatags.each do |metatag| From 2c1da660fd7c84717e91513bbe6967ca5dcb2b6a Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 19:02:49 -0600 Subject: [PATCH 046/132] tags: allow tag abbreviations in searches and during tagging. Expand the tag abbreviation system introduced in b0be8ae45 so that it works in searches and when tagging posts, not just in autocomplete. For example, you can tag a post with /evth and it will add the tag eyebrows_visible_through_hair. You can search for /evth and it will search for the tag eyebrows_visible_through_hair. Some more examples: * /ops is short for one-piece_swimsuit * /hooe is short for hair_over_one_eye * /saol is short for standing_on_one_leg * /tlozbotw is short for the_legend_of_zelda:_breath_of_the_wild If two tags have the same abbreviation, then the larger tag takes precedence. For example, /be is short for blue_eyes, not brown_eyes, because blue_eyes is the bigger tag. If there is an existing shortcut alias that conflicts with the abbreviation, then the alias take precedence. For example, /sh is short for suzumiya_haruhi, not short_hair, because there's an old alias for /sh -> suzumiya_haruhi. --- app/logical/autocomplete_service.rb | 6 +++++- app/logical/concerns/searchable.rb | 2 +- app/models/tag.rb | 5 +++++ app/models/tag_alias.rb | 8 ++++++++ config/initializers/core_extensions.rb | 5 +++++ test/unit/autocomplete_service_test.rb | 8 ++++++++ test/unit/post_query_builder_test.rb | 11 +++++++++++ test/unit/post_test.rb | 27 ++++++++++++++++++++++++++ test/unit/tag_alias_test.rb | 12 ++++++++++++ 9 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 0c5d39f93..f75101098 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -67,9 +67,13 @@ class AutocompleteService def autocomplete_tag(string) if string.starts_with?("/") string = string + "*" unless string.include?("*") + results = tag_matches(string) results += tag_abbreviation_matches(string) - results = results.sort_by { |r| [r[:antecedent].to_s.size, -r[:post_count]] } + results = results.sort_by do |r| + [r[:type] == "tag-alias" ? 0 : 1, r[:antecedent].to_s.size, -r[:post_count]] + end + results = results.uniq { |r| r[:value] }.take(limit) elsif string.include?("*") results = tag_matches(string) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index cac920cc8..a83a9c83a 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -53,7 +53,7 @@ module Searchable end def where_iequals(attr, value) - where_ilike(attr, value.gsub(/\\/, '\\\\').gsub(/\*/, '\*')) + where_ilike(attr, value.escape_wildcards) end # https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-POSIX-REGEXP diff --git a/app/models/tag.rb b/app/models/tag.rb index 27255bed9..527f8a797 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -270,6 +270,11 @@ class Tag < ApplicationRecord where("regexp_replace(tags.name, ?, '\\1', 'g') LIKE ?", ABBREVIATION_REGEXP.source, abbrev.to_escaped_for_sql_like) end + def find_by_abbreviation(abbrev) + abbrev = abbrev.delete_prefix("/") + abbreviation_matches(abbrev.escape_wildcards).order(post_count: :desc).first + end + def search(params) q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :category, :post_count, :name, :wiki_page, :artist, :antecedent_alias, :consequent_aliases, :antecedent_implications, :consequent_implications, :dtext_links) diff --git a/app/models/tag_alias.rb b/app/models/tag_alias.rb index 9c460e27a..b3cb9c4f2 100644 --- a/app/models/tag_alias.rb +++ b/app/models/tag_alias.rb @@ -7,7 +7,15 @@ class TagAlias < TagRelationship def self.to_aliased(names) names = Array(names).map(&:to_s) return [] if names.empty? + aliases = active.where(antecedent_name: names).map { |ta| [ta.antecedent_name, ta.consequent_name] }.to_h + + abbreviations = names.select { |name| name.starts_with?("/") && !aliases.has_key?(name) } + abbreviations.each do |abbrev| + tag = Tag.nonempty.find_by_abbreviation(abbrev) + aliases[abbrev] = tag.name if tag.present? + end + names.map { |name| aliases[name] || name } end diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index 61f85f9d0..ef7dba48d 100644 --- a/config/initializers/core_extensions.rb +++ b/config/initializers/core_extensions.rb @@ -16,6 +16,11 @@ module Danbooru string end + # escape \ and * characters so that they're treated literally in LIKE searches. + def escape_wildcards + gsub(/\\/, '\\\\').gsub(/\*/, '\*') + end + def to_escaped_for_tsquery_split scan(/\S+/).map {|x| x.to_escaped_for_tsquery}.join(" & ") end diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index 1abe96f80..e0463f36e 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -97,6 +97,14 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) end + + should "list aliases before abbreviations" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + + assert_autocomplete_equals(%w[hakurei_reimu hair_ribbon], "/hr", :tag_query) + end end should "autocomplete tags from wiki and artist other names" do diff --git a/test/unit/post_query_builder_test.rb b/test/unit/post_query_builder_test.rb index 193cbbad5..f4882c287 100644 --- a/test/unit/post_query_builder_test.rb +++ b/test/unit/post_query_builder_test.rb @@ -1018,6 +1018,17 @@ class PostQueryBuilderTest < ActiveSupport::TestCase assert_tag_match([post2], "-kitten") end + should "resolve abbreviations to the actual tag" do + tag1 = create(:tag, name: "hair_ribbon", post_count: 300_000) + tag2 = create(:tag, name: "hakurei_reimu", post_count: 50_000) + ta1 = create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + post1 = create(:post, tag_string: "hair_ribbon") + post2 = create(:post, tag_string: "hakurei_reimu") + + assert_tag_match([post2], "/hr") + assert_tag_match([post1], "-/hr") + end + should "fail for more than 6 tags" do post1 = create(:post, rating: "s") diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index d08737624..ee470de05 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -598,6 +598,10 @@ class PostTest < ActiveSupport::TestCase context "tagged with a valid tag" do subject { @post } + setup do + create(:tag, name: "hakurei_reimu") + end + should allow_value("touhou 100%").for(:tag_string) should allow_value("touhou FOO").for(:tag_string) should allow_value("touhou -foo").for(:tag_string) @@ -618,6 +622,8 @@ class PostTest < ActiveSupport::TestCase # \u3000 = ideographic space, \u00A0 = no-break space should allow_value("touhou\u3000foo").for(:tag_string) should allow_value("touhou\u00A0foo").for(:tag_string) + + should allow_value("/hr").for(:tag_string) end context "tagged with an invalid tag" do @@ -661,6 +667,16 @@ class PostTest < ActiveSupport::TestCase end end + context "tagged with an abbreviation" do + should "expand the abbreviation" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + + @post.update!(tag_string: "aaa /hr") + assert_equal("aaa hair_ribbon", @post.reload.tag_string) + end + end + context "tagged with a metatag" do context "for typing a tag" do setup do @@ -1190,6 +1206,17 @@ class PostTest < ActiveSupport::TestCase assert_equal("aaa", @post.tag_string) end + + should "resolve abbreviations" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + + @post.update!(tag_string: "aaa hair_ribbon hakurei_reimu") + assert_equal("aaa hair_ribbon hakurei_reimu", @post.reload.tag_string) + + @post.update!(tag_string: "aaa hair_ribbon hakurei_reimu -/hr") + assert_equal("aaa hakurei_reimu", @post.reload.tag_string) + end end context "tagged with animated_gif or animated_png" do diff --git a/test/unit/tag_alias_test.rb b/test/unit/tag_alias_test.rb index 1aeb005b9..4b5a44bc8 100644 --- a/test/unit/tag_alias_test.rb +++ b/test/unit/tag_alias_test.rb @@ -79,6 +79,18 @@ class TagAliasTest < ActiveSupport::TestCase assert_equal(["bbb", "bbb"], TagAlias.to_aliased(["aaa", "aaa"])) end + should "handle abbreviations in TagAlias.to_aliased" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + create(:tag, name: "kirisama_marisa", post_count: 50_000) + create(:tag, name: "kaname_madoka", post_count: 20_000) + create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + + assert_equal(["hakurei_reimu"], TagAlias.to_aliased(["/hr"])) + assert_equal(["kirisama_marisa"], TagAlias.to_aliased(["/km"])) + assert_equal(["hakurei_reimu", "kirisama_marisa"], TagAlias.to_aliased(["/hr", "/km"])) + end + context "saved searches" do should "move saved searches" do @ss1 = create(:saved_search, query: "123 ... 456", user: CurrentUser.user) From 3d1ff9dff9cde553c781bcffcabeb02497663a4b Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 20:08:33 -0600 Subject: [PATCH 047/132] autocomplete: fix not detecting correct tag in edit box. --- app/javascript/src/javascripts/autocomplete.js.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index 4046ccc7d..c3269f358 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -110,7 +110,7 @@ Autocomplete.initialize_tag_autocomplete = function() { Autocomplete.current_term = function($input) { let query = $input.get(0).value; let caret = $input.get(0).selectionStart; - let match = query.substring(0, caret).match(/\S*/); + let match = query.substring(0, caret).match(/\S*$/); return match[0]; }; From 25069865b7560c80e7a7c95881f4d1f21bd857c0 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 20:21:40 -0600 Subject: [PATCH 048/132] ip bans: add search form. * Add IP ban search form to /ip_bans page. * Make some attributes searchable that weren't previously searchable. --- app/models/ip_ban.rb | 12 ++++++++---- app/views/ip_bans/index.html.erb | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/models/ip_ban.rb b/app/models/ip_ban.rb index 223539f43..d69f41bbf 100644 --- a/app/models/ip_ban.rb +++ b/app/models/ip_ban.rb @@ -25,14 +25,18 @@ class IpBan < ApplicationRecord end def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :reason, :creator) + q = search_attributes(params, :id, :created_at, :updated_at, :ip_addr, :reason, :is_deleted, :category, :hit_count, :last_hit_at, :creator) q = q.text_attribute_matches(:reason, params[:reason_matches]) - if params[:ip_addr].present? - q = q.where("ip_addr = ?", params[:ip_addr]) + case params[:order] + when /\A(created_at|updated_at|last_hit_at)(?:_(asc|desc))?\z/i + dir = $2 || :desc + q = q.order($1 => dir).order(id: :desc) + else + q = q.apply_default_order(params) end - q.apply_default_order(params) + q end def create_mod_action diff --git a/app/views/ip_bans/index.html.erb b/app/views/ip_bans/index.html.erb index 5a68e17f9..b3bdf29db 100644 --- a/app/views/ip_bans/index.html.erb +++ b/app/views/ip_bans/index.html.erb @@ -2,6 +2,16 @@

    IP Bans

    + <%= search_form_for(ip_bans_path) do |f| %> + <%= f.input :ip_addr, label: "IP Addr", hint: "Use /24 for subnet", input_html: { value: params[:search][:ip_addr] } %> + <%= f.input :reason, input_html: { value: params[:search][:reason] } %> + <%= f.input :creator_name, label: "Creator", input_html: { value: params[:search][:creator_name], "data-autocomplete": "user" } %> + <%= f.input :category, collection: IpBan.categories, include_blank: true, selected: params[:search][:category] %> + <%= f.input :is_deleted, label: "Deleted?", collection: ["Yes", "No"], include_blank: true, input_html: { value: params[:search][:is_deleted] } %> + <%= f.input :order, collection: [%w[Newest created_at], %w[Oldest created_at_asc], %w[Last\ Seen last_hit_at]], include_blank: true, selected: params[:search][:order] %> + <%= f.submit "Search" %> + <% end %> + <%= table_for @ip_bans, class: "striped autofit", width: "100%" do |t| %> <% t.column "IP Address" do |ip_ban| %> <%= link_to_ip ip_ban.subnetted_ip %> From 2c92794ebaecbf5f4f57ba76d24f0514a53d42b8 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 20:35:38 -0600 Subject: [PATCH 049/132] wiki: include search form on search results page. Include the search form on the search results page so you can more easily refine your search. --- app/views/wiki_pages/_search.html.erb | 9 +++++++++ app/views/wiki_pages/index.html.erb | 2 ++ app/views/wiki_pages/search.html.erb | 12 +----------- 3 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 app/views/wiki_pages/_search.html.erb diff --git a/app/views/wiki_pages/_search.html.erb b/app/views/wiki_pages/_search.html.erb new file mode 100644 index 000000000..cf9bd1834 --- /dev/null +++ b/app/views/wiki_pages/_search.html.erb @@ -0,0 +1,9 @@ +<%= search_form_for(wiki_pages_path) do |f| %> + <%= f.input :title_normalize, label: "Title", hint: "Use * for wildcard", input_html: { value: params[:search][:title_normalize], "data-autocomplete": "wiki-page" } %> + <%= f.input :other_names_match, label: "Other names", hint: "Use * for wildcard", input_html: { value: params[:search][:other_names_match] } %> + <%= f.input :body_matches, label: "Body", hint: "Use * for wildcard", input_html: { value: params[:search][:body_matches] } %> + <%= f.input :linked_to, hint: "Find wikis linking to this wiki", input_html: { value: params[:search][:linked_to], "data-autocomplete": "wiki-page" } %> + <%= f.input :is_deleted, label: "Deleted?", as: :select, include_blank: true, selected: params[:search][:is_deleted] %> + <%= f.input :order, collection: [%w[Newest created_at], %w[Title title], %w[Posts post_count]], include_blank: true, selected: params[:search][:order] %> + <%= f.submit "Search" %> +<% end %> diff --git a/app/views/wiki_pages/index.html.erb b/app/views/wiki_pages/index.html.erb index 7a03e3d34..c9dc8c4a7 100644 --- a/app/views/wiki_pages/index.html.erb +++ b/app/views/wiki_pages/index.html.erb @@ -3,6 +3,8 @@ <% content_for(:content) do %>

    Wiki

    + <%= render "search" %> + <%= table_for @wiki_pages, width: "100%" do |t| %> <% t.column "Title" do |wiki_page| %> <%= link_to_wiki wiki_page.title %> diff --git a/app/views/wiki_pages/search.html.erb b/app/views/wiki_pages/search.html.erb index 3297d9695..c21832632 100644 --- a/app/views/wiki_pages/search.html.erb +++ b/app/views/wiki_pages/search.html.erb @@ -1,16 +1,6 @@
    From 53653372ec34182b1a5a3f18b6119f2164b21449 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 21:36:43 -0600 Subject: [PATCH 050/132] notes: include search form on search results page. Also eliminate /notes/search endpoint. --- app/controllers/notes_controller.rb | 3 --- app/views/notes/_secondary_links.html.erb | 1 - app/views/notes/index.html.erb | 10 +++++++++- app/views/notes/search.html.erb | 15 --------------- app/views/static/site_map.html.erb | 1 - config/routes.rb | 3 --- 6 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 app/views/notes/search.html.erb diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb index ec4246acb..db320a92a 100644 --- a/app/controllers/notes_controller.rb +++ b/app/controllers/notes_controller.rb @@ -1,9 +1,6 @@ class NotesController < ApplicationController respond_to :html, :xml, :json, :js - def search - end - def index @notes = authorize Note.paginated_search(params) @notes = @notes.includes(:post) if request.format.html? diff --git a/app/views/notes/_secondary_links.html.erb b/app/views/notes/_secondary_links.html.erb index c3497b7ff..c6b1c2b4c 100644 --- a/app/views/notes/_secondary_links.html.erb +++ b/app/views/notes/_secondary_links.html.erb @@ -2,7 +2,6 @@ <%= quick_search_form_for(:body_matches, notes_path, "notes") %> <%= subnav_link_to "Listing", notes_path %> <%= subnav_link_to "Posts", posts_path(:tags => "order:note") %> - <%= subnav_link_to "Search", search_notes_path %> <%= subnav_link_to "History", note_versions_path %> <%= subnav_link_to "Requests", posts_path(:tags => "translation_request") %> <%= subnav_link_to "Help", wiki_page_path("help:notes") %> diff --git a/app/views/notes/index.html.erb b/app/views/notes/index.html.erb index c52af8bdb..76b6b9099 100644 --- a/app/views/notes/index.html.erb +++ b/app/views/notes/index.html.erb @@ -4,6 +4,14 @@

    Notes

    + <%= search_form_for(notes_path) do |f| %> + <%= f.hidden_field :group_by, value: "note" %> + + <%= f.input :body_matches, label: "Note", hint: "Use * for wildcard", input_html: { value: params[:search][:body_matches] } %> + <%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %> + <%= f.submit "Search" %> + <% end %> + <%= table_for @notes, class: "striped autofit" do |t| %> <% t.column "Post" do |note| %> <%= link_to note.post_id, note.post %> @@ -12,7 +20,7 @@ <%= link_to "#{note.id}.#{note.version}", post_path(note.post_id, anchor: "note-#{note.id}") %> <%= link_to "»", note_versions_path(search: { note_id: note.id }) %> <% end %> - <% t.column "Body", td: { class: "col-expand" } do |note| %> + <% t.column "Text", td: { class: "col-expand" } do |note| %> <%= note.body %> <% unless note.is_active? %> (deleted) diff --git a/app/views/notes/search.html.erb b/app/views/notes/search.html.erb deleted file mode 100644 index 4aabd7e71..000000000 --- a/app/views/notes/search.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -
    - -
    - -<%= render "secondary_links" %> diff --git a/app/views/static/site_map.html.erb b/app/views/static/site_map.html.erb index 4e82675d2..893ca7b3b 100644 --- a/app/views/static/site_map.html.erb +++ b/app/views/static/site_map.html.erb @@ -57,7 +57,6 @@
  • Notes

  • <%= link_to_wiki "Help", "help:notes" %>
  • <%= link_to("Listing", notes_path) %>
  • -
  • <%= link_to("Search", search_notes_path) %>
  • <%= link_to("Changes", note_versions_path) %>
    • diff --git a/config/routes.rb b/config/routes.rb index 9b916feaf..6244fed13 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -142,9 +142,6 @@ Rails.application.routes.draw do resources :modqueue, only: [:index] resources :news_updates resources :notes do - collection do - get :search - end member do put :revert end From 5fc99b9946808a592c2737297ff948f0995e9d43 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 18 Dec 2020 02:04:43 -0600 Subject: [PATCH 051/132] Upgrade to Rails 6.1. * Swap out activerecord-hierarchical_query gem for some guy's patched version because the mainline version is incompatible with 6.1. * Disable meta_request gem because it hangs puma on startup on 6.1. --- Gemfile | 4 +- Gemfile.lock | 151 ++++++++++++++++++++++++++------------------------- 2 files changed, 79 insertions(+), 76 deletions(-) diff --git a/Gemfile b/Gemfile index 92332c2e6..eed0df543 100644 --- a/Gemfile +++ b/Gemfile @@ -40,7 +40,7 @@ gem 'puma' gem 'scenic' gem 'ipaddress_2' gem 'http' -gem 'activerecord-hierarchical_query' +gem 'activerecord-hierarchical_query', git: "https://github.com/walski/activerecord-hierarchical_query", branch: "rails-6-1" gem 'http-cookie', git: "https://github.com/danbooru/http-cookie" gem 'pundit' gem 'mail' @@ -60,7 +60,7 @@ end group :development do gem 'rubocop' gem 'rubocop-rails' - gem 'meta_request' + # gem 'meta_request' # hangs on Rails 6.1 gem 'rack-mini-profiler' gem 'stackprof' gem 'flamegraph' diff --git a/Gemfile.lock b/Gemfile.lock index 2fb7f8a00..021635222 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,71 +12,81 @@ GIT dtext_rb (1.10.6) nokogiri (~> 1.8) +GIT + remote: https://github.com/walski/activerecord-hierarchical_query + revision: 3d6663307ed2f6a23347084c04700a26c7e7bb55 + branch: rails-6-1 + specs: + activerecord-hierarchical_query (1.2.3) + activerecord (>= 5.0, < 6.2) + pg (>= 0.21, < 1.3) + GEM remote: https://rubygems.org/ specs: - actioncable (6.0.3.4) - actionpack (= 6.0.3.4) + actioncable (6.1.0) + actionpack (= 6.1.0) + activesupport (= 6.1.0) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.3.4) - actionpack (= 6.0.3.4) - activejob (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) + actionmailbox (6.1.0) + actionpack (= 6.1.0) + activejob (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) mail (>= 2.7.1) - actionmailer (6.0.3.4) - actionpack (= 6.0.3.4) - actionview (= 6.0.3.4) - activejob (= 6.0.3.4) + actionmailer (6.1.0) + actionpack (= 6.1.0) + actionview (= 6.1.0) + activejob (= 6.1.0) + activesupport (= 6.1.0) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.3.4) - actionview (= 6.0.3.4) - activesupport (= 6.0.3.4) - rack (~> 2.0, >= 2.0.8) + actionpack (6.1.0) + actionview (= 6.1.0) + activesupport (= 6.1.0) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.3.4) - actionpack (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) + actiontext (6.1.0) + actionpack (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) nokogiri (>= 1.8.5) - actionview (6.0.3.4) - activesupport (= 6.0.3.4) + actionview (6.1.0) + activesupport (= 6.1.0) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.3.4) - activesupport (= 6.0.3.4) + activejob (6.1.0) + activesupport (= 6.1.0) globalid (>= 0.3.6) - activemodel (6.0.3.4) - activesupport (= 6.0.3.4) + activemodel (6.1.0) + activesupport (= 6.1.0) activemodel-serializers-xml (1.0.2) activemodel (> 5.x) activesupport (> 5.x) builder (~> 3.1) - activerecord (6.0.3.4) - activemodel (= 6.0.3.4) - activesupport (= 6.0.3.4) - activerecord-hierarchical_query (1.2.3) - activerecord (>= 5.0, < 6.1) - pg (>= 0.21, < 1.3) - activestorage (6.0.3.4) - actionpack (= 6.0.3.4) - activejob (= 6.0.3.4) - activerecord (= 6.0.3.4) + activerecord (6.1.0) + activemodel (= 6.1.0) + activesupport (= 6.1.0) + activestorage (6.1.0) + actionpack (= 6.1.0) + activejob (= 6.1.0) + activerecord (= 6.1.0) + activesupport (= 6.1.0) marcel (~> 0.3.1) - activesupport (6.0.3.4) + mimemagic (~> 0.3.2) + activesupport (6.1.0) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2, >= 2.2.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) @@ -84,13 +94,13 @@ GEM ansi (1.5.0) ast (2.4.1) aws-eventstream (1.1.0) - aws-partitions (1.405.0) + aws-partitions (1.409.0) aws-sdk-core (3.110.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-sqs (1.34.0) + aws-sdk-sqs (1.35.0) aws-sdk-core (~> 3, >= 3.109.0) aws-sigv4 (~> 1.1) aws-sigv4 (1.2.2) @@ -176,7 +186,7 @@ GEM concurrent-ruby (~> 1.0) ipaddress_2 (0.13.0) jmespath (1.4.0) - json (2.3.1) + json (2.4.1) jwt (2.2.2) kgio (2.11.3) listen (3.3.3) @@ -191,9 +201,6 @@ GEM mimemagic (~> 0.3.2) memoist (0.16.2) memory_profiler (1.0.0) - meta_request (0.7.2) - rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 7) method_source (1.0.0) mimemagic (0.3.5) mini_mime (1.0.2) @@ -247,40 +254,38 @@ GEM pundit (2.1.0) activesupport (>= 3.0.0) rack (2.2.3) - rack-contrib (2.3.0) - rack (~> 2.0) rack-mini-profiler (2.2.0) rack (>= 1.2.0) rack-proxy (0.6.5) rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.3.4) - actioncable (= 6.0.3.4) - actionmailbox (= 6.0.3.4) - actionmailer (= 6.0.3.4) - actionpack (= 6.0.3.4) - actiontext (= 6.0.3.4) - actionview (= 6.0.3.4) - activejob (= 6.0.3.4) - activemodel (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) - bundler (>= 1.3.0) - railties (= 6.0.3.4) + rails (6.1.0) + actioncable (= 6.1.0) + actionmailbox (= 6.1.0) + actionmailer (= 6.1.0) + actionpack (= 6.1.0) + actiontext (= 6.1.0) + actionview (= 6.1.0) + activejob (= 6.1.0) + activemodel (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) + bundler (>= 1.15.0) + railties (= 6.1.0) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (6.0.3.4) - actionpack (= 6.0.3.4) - activesupport (= 6.0.3.4) + railties (6.1.0) + actionpack (= 6.1.0) + activesupport (= 6.1.0) method_source rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) + thor (~> 1.0) rainbow (3.0.0) raindrops (0.19.1) rake (13.0.1) @@ -309,7 +314,7 @@ GEM unicode-display_width (>= 1.4.0, < 2.0) rubocop-ast (1.3.0) parser (>= 2.7.1.5) - rubocop-rails (2.9.0) + rubocop-rails (2.9.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 0.90.0, < 2.0) @@ -360,9 +365,8 @@ GEM multi_json (~> 1.0) stripe (> 5, < 6) thor (1.0.1) - thread_safe (0.3.6) - tzinfo (1.2.8) - thread_safe (~> 0.1) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext unf_ext (0.0.7.7) @@ -392,7 +396,7 @@ PLATFORMS DEPENDENCIES activemodel-serializers-xml - activerecord-hierarchical_query + activerecord-hierarchical_query! addressable aws-sdk-sqs (~> 1) bcrypt @@ -421,7 +425,6 @@ DEPENDENCIES mail memoist memory_profiler - meta_request minitest-ci minitest-reporters mocha From 6849a3d68b927aac64a5bd8d0b50cd93fbe63114 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 00:26:27 -0600 Subject: [PATCH 052/132] Update app files to Rails 6.1 defaults. --- bin/rails | 4 +- bin/rake | 31 +-------- bin/setup | 8 +-- bin/yarn | 14 ++++- config.ru | 3 +- config/boot.rb | 4 +- config/environment.rb | 2 +- config/environments/development.rb | 30 +++++---- config/environments/production.rb | 34 +++++----- config/environments/test.rb | 16 +++-- config/initializers/backtrace_silencers.rb | 7 ++- .../new_framework_defaults_6_1.rb | 63 +++++++++++++++++++ config/initializers/permissions_policy.rb | 11 ++++ config/puma.rb | 41 +++++------- 14 files changed, 160 insertions(+), 108 deletions(-) create mode 100644 config/initializers/new_framework_defaults_6_1.rb create mode 100644 config/initializers/permissions_policy.rb diff --git a/bin/rails b/bin/rails index 073966023..6fb4e4051 100755 --- a/bin/rails +++ b/bin/rails @@ -1,4 +1,4 @@ #!/usr/bin/env ruby APP_PATH = File.expand_path('../config/application', __dir__) -require_relative '../config/boot' -require 'rails/commands' +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake index 9275675e8..4fbf10b96 100755 --- a/bin/rake +++ b/bin/rake @@ -1,29 +1,4 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'rake' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require "pathname" -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", - Pathname.new(__FILE__).realpath) - -bundle_binstub = File.expand_path("../bundle", __FILE__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("rake", "rake") +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/setup b/bin/setup index 5853b5ea8..90700ac4f 100755 --- a/bin/setup +++ b/bin/setup @@ -1,5 +1,5 @@ #!/usr/bin/env ruby -require 'fileutils' +require "fileutils" # path to your application root. APP_ROOT = File.expand_path('..', __dir__) @@ -9,8 +9,8 @@ def system!(*args) end FileUtils.chdir APP_ROOT do - # This script is a way to setup or update your development environment automatically. - # This script is idempotent, so that you can run it at anytime and get an expectable outcome. + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. # Add necessary setup steps to this file. puts '== Installing dependencies ==' @@ -18,7 +18,7 @@ FileUtils.chdir APP_ROOT do system('bundle check') || system!('bundle install') # Install JavaScript dependencies - # system('bin/yarn') + system! 'bin/yarn' # puts "\n== Copying sample files ==" # unless File.exist?('config/database.yml') diff --git a/bin/yarn b/bin/yarn index 460dd565b..241546e51 100755 --- a/bin/yarn +++ b/bin/yarn @@ -1,9 +1,17 @@ #!/usr/bin/env ruby +require 'pathname' + APP_ROOT = File.expand_path('..', __dir__) Dir.chdir(APP_ROOT) do - begin - exec "yarnpkg", *ARGV - rescue Errno::ENOENT + executable_path = ENV["PATH"].split(File::PATH_SEPARATOR).find do |path| + normalized_path = File.expand_path(path) + + normalized_path != __dir__ && File.executable?(Pathname.new(normalized_path).join('yarn')) + end + + if executable_path + exec File.expand_path(Pathname.new(executable_path).join('yarn')), *ARGV + else $stderr.puts "Yarn executable was not detected in the system." $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" exit 1 diff --git a/config.ru b/config.ru index 401c7482f..a0a19dff8 100644 --- a/config.ru +++ b/config.ru @@ -1,6 +1,6 @@ # This file is used by Rack-based servers to start the application. -require ::File.expand_path('../config/environment', __FILE__) +require_relative "config/environment" if defined?(Unicorn) && Rails.env.production? # Unicorn self-process killer @@ -14,3 +14,4 @@ if defined?(Unicorn) && Rails.env.production? end run Rails.application +Rails.application.load_server diff --git a/config/boot.rb b/config/boot.rb index b9e460cef..3cda23b4d 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,4 +1,4 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) -require 'bundler/setup' # Set up gems listed in the Gemfile. -require 'bootsnap/setup' # Speed up boot time by caching expensive operations. +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/environment.rb b/config/environment.rb index 426333bb4..cac531577 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,5 +1,5 @@ # Load the Rails application. -require_relative 'application' +require_relative "application" # Initialize the Rails application. Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb index 9b5b48df6..f0d62dc0c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,8 +1,10 @@ +require "active_support/core_ext/integer/time" + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - # In the development environment your application's code is reloaded on - # every request. This slows down response time but is perfect for development + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. config.cache_classes = false @@ -28,9 +30,6 @@ Rails.application.configure do config.cache_store = :null_store end - # Store uploaded files on the local file system (see config/storage.yml for options). - # config.active_storage.service = :local - # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false @@ -39,24 +38,29 @@ Rails.application.configure do # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true - # Debug mode disables concatenation and preprocessing of assets. - # This option may cause significant delays in view rendering with a large - # number of complex assets. - # config.assets.debug = true - - # Suppress logger output for asset requests. - # config.assets.quiet = true # Raises error for missing translations. - # config.action_view.raise_on_missing_translations = true + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true end diff --git a/config/environments/production.rb b/config/environments/production.rb index 4550af040..21706aae9 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,5 @@ +require "active_support/core_ext/integer/time" + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. @@ -22,36 +24,22 @@ Rails.application.configure do # Apache or NGINX already handles this. config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? - # Compress CSS using a preprocessor. - # config.assets.css_compressor = :sass - - # Do not fallback to assets pipeline if a precompiled asset is missed. - # config.assets.compile = false - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.action_controller.asset_host = 'http://assets.example.com' + # config.asset_host = 'http://assets.example.com' # Specifies the header that your server uses for sending files. # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX - # Store uploaded files on the local file system (see config/storage.yml for options). - # config.active_storage.service = :local - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = 'wss://example.com/cable' - # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # config.force_ssl = true - # Use the lowest log level to ensure availability of diagnostic information - # when problems arise. + # Include generic and useful information about system operation, but avoid logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). config.log_level = :error # Prepend all log lines with the following tags. - config.log_tags = [:request_id] + config.log_tags = [ :request_id ] # Use a different cache store in production. # config.cache_store = :mem_cache_store @@ -70,16 +58,22 @@ Rails.application.configure do # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = [I18n.default_locale] + config.i18n.fallbacks = true # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify + # Log disallowed deprecations. + config.active_support.disallowed_deprecation = :log + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + # Use default logging formatter so that PID and timestamp are not suppressed. config.log_formatter = ::Logger::Formatter.new # Use a different logger for distributed setups. - # require 'syslog/logger' + # require "syslog/logger" # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') if ENV["RAILS_LOG_TO_STDOUT"].present? diff --git a/config/environments/test.rb b/config/environments/test.rb index eebb71d5e..d66c042f9 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,3 +1,5 @@ +require "active_support/core_ext/integer/time" + # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped @@ -30,9 +32,6 @@ Rails.application.configure do # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false - # Store uploaded files on the local file system in a temporary directory. - # config.active_storage.service = :test - config.action_mailer.perform_caching = false # Tell Action Mailer not to deliver emails to the real world. @@ -43,6 +42,15 @@ Rails.application.configure do # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + # Raises error for missing translations. - # config.action_view.raise_on_missing_translations = true + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true end diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb index 59385cdf3..33699c309 100644 --- a/config/initializers/backtrace_silencers.rb +++ b/config/initializers/backtrace_silencers.rb @@ -1,7 +1,8 @@ # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. -# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } +# Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. -# Rails.backtrace_cleaner.remove_silencers! +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code +# by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". +Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] diff --git a/config/initializers/new_framework_defaults_6_1.rb b/config/initializers/new_framework_defaults_6_1.rb new file mode 100644 index 000000000..629888deb --- /dev/null +++ b/config/initializers/new_framework_defaults_6_1.rb @@ -0,0 +1,63 @@ +# Be sure to restart your server when you modify this file. +# +# This file contains migration options to ease your Rails 6.1 upgrade. +# +# Once upgraded flip defaults one by one to migrate to the new default. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. + +# Support for inversing belongs_to -> has_many Active Record associations. +# Rails.application.config.active_record.has_many_inversing = true + +# Track Active Storage variants in the database. +# Rails.application.config.active_storage.track_variants = true + +# Apply random variation to the delay when retrying failed jobs. +# Rails.application.config.active_job.retry_jitter = 0.15 + +# Stop executing `after_enqueue`/`after_perform` callbacks if +# `before_enqueue`/`before_perform` respectively halts with `throw :abort`. +# Rails.application.config.active_job.skip_after_callbacks_if_terminated = true + +# Specify cookies SameSite protection level: either :none, :lax, or :strict. +# +# This change is not backwards compatible with earlier Rails versions. +# It's best enabled when your entire app is migrated and stable on 6.1. +# Rails.application.config.action_dispatch.cookies_same_site_protection = :lax + +# Generate CSRF tokens that are encoded in URL-safe Base64. +# +# This change is not backwards compatible with earlier Rails versions. +# It's best enabled when your entire app is migrated and stable on 6.1. +# Rails.application.config.action_controller.urlsafe_csrf_tokens = true + +# Specify whether `ActiveSupport::TimeZone.utc_to_local` returns a time with an +# UTC offset or a UTC time. +# ActiveSupport.utc_to_local_returns_utc_offset_times = true + +# Change the default HTTP status code to `308` when redirecting non-GET/HEAD +# requests to HTTPS in `ActionDispatch::SSL` middleware. +# Rails.application.config.action_dispatch.ssl_default_redirect_status = 308 + +# Use new connection handling API. For most applications this won't have any +# effect. For applications using multiple databases, this new API provides +# support for granular connection swapping. +# Rails.application.config.active_record.legacy_connection_handling = false + +# Make `form_with` generate non-remote forms by default. +# Rails.application.config.action_view.form_with_generates_remote_forms = false + +# Set the default queue name for the analysis job to the queue adapter default. +# Rails.application.config.active_storage.queues.analysis = nil + +# Set the default queue name for the purge job to the queue adapter default. +# Rails.application.config.active_storage.queues.purge = nil + +# Set the default queue name for the incineration job to the queue adapter default. +# Rails.application.config.action_mailbox.queues.incineration = nil + +# Set the default queue name for the routing job to the queue adapter default. +# Rails.application.config.action_mailbox.queues.routing = nil + +# Set the default queue name for the mail deliver job to the queue adapter default. +# Rails.application.config.action_mailer.deliver_later_queue_name = nil diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 000000000..00f64d71b --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,11 @@ +# Define an application-wide HTTP permissions policy. For further +# information see https://developers.google.com/web/updates/2018/06/feature-policy +# +# Rails.application.config.permissions_policy do |f| +# f.camera :none +# f.gyroscope :none +# f.microphone :none +# f.usb :none +# f.fullscreen :self +# f.payment :self, "https://secure.example.com" +# end diff --git a/config/puma.rb b/config/puma.rb index 1e19380dc..d9b3e836c 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -4,19 +4,28 @@ # the maximum value specified for Puma. Default is set to 5 threads for minimum # and maximum; this matches the default thread size of Active Record. # -threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -threads threads_count, threads_count +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" # Specifies the `port` that Puma will listen on to receive requests; default is 3000. # -port ENV.fetch("PORT") { 3000 } +port ENV.fetch("PORT") { 3000 } # Specifies the `environment` that Puma will run in. # environment ENV.fetch("RAILS_ENV") { "development" } +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + # Specifies the number of `workers` to boot in clustered mode. -# Workers are forked webserver processes. If using threads and workers together +# Workers are forked web server processes. If using threads and workers together # the concurrency of the application would be max `threads` * `workers`. # Workers do not work on JRuby or Windows (both of which do not support # processes). @@ -26,31 +35,9 @@ environment ENV.fetch("RAILS_ENV") { "development" } # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. If you use this option -# you need to make sure to reconnect any threads in the `on_worker_boot` -# block. +# process behavior so workers use less memory. # # preload_app! -# If you are preloading your application and using Active Record, it's -# recommended that you close any connections to the database before workers -# are forked to prevent connection leakage. -# -# before_fork do -# ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) -# end - -# The code in the `on_worker_boot` will be called if you are using -# clustered mode by specifying a number of `workers`. After each worker -# process is booted, this block will be run. If you are using the `preload_app!` -# option, you will want to use this block to reconnect to any threads -# or connections that may have been created at application boot, as Ruby -# cannot share connections between processes. -# -# on_worker_boot do -# ActiveRecord::Base.establish_connection if defined?(ActiveRecord) -# end -# - # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart From c97186abd7889b942b6acbdc5b6a5442b6d8f74f Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 00:27:20 -0600 Subject: [PATCH 053/132] /status: add missing template files. Add missing templates that were forgotten in 7a87225ac. --- app/views/status/_list.html.erb | 7 +++++++ app/views/status/_table.html.erb | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 app/views/status/_list.html.erb create mode 100644 app/views/status/_table.html.erb diff --git a/app/views/status/_list.html.erb b/app/views/status/_list.html.erb new file mode 100644 index 000000000..e5b304b21 --- /dev/null +++ b/app/views/status/_list.html.erb @@ -0,0 +1,7 @@ +<%# hash %> +
      + <% hash.each do |key, value| %> +
      <%= key.to_s.humanize %>
      +
      <%= value %>
      + <% end %> +
      diff --git a/app/views/status/_table.html.erb b/app/views/status/_table.html.erb new file mode 100644 index 000000000..05678ff5c --- /dev/null +++ b/app/views/status/_table.html.erb @@ -0,0 +1,19 @@ +<%# rows %> + + + + + <% rows.first.keys.each do |key| %> + + <% end %> + + + + <% rows.each do |row| %> + + <% row.each do |key, value| %> + + <% end %> + + <% end %> +
      <%= key.humanize %>
      <%= value %>
      From 09e3146819958df64b065248c19342ab4161ed08 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 00:51:34 -0600 Subject: [PATCH 054/132] artist finder: add blog.livedoor.jp to blacklist. --- app/logical/artist_finder.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/logical/artist_finder.rb b/app/logical/artist_finder.rb index a0e8f21b9..bd24b8645 100644 --- a/app/logical/artist_finder.rb +++ b/app/logical/artist_finder.rb @@ -60,6 +60,7 @@ module ArtistFinder "iwara.tv/users", # http://ecchi.iwara.tv/users/marumega "kym-cdn.com", "livedoor.blogimg.jp", + "blog.livedoor.jp", # http://blog.livedoor.jp/ac370ml "monappy.jp", "monappy.jp/u", # https://monappy.jp/u/abara_bone "mstdn.jp", # https://mstdn.jp/@oneb From 4cb39422b21c04e22a88acf4b8c20f1a31244795 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 14:18:05 -0600 Subject: [PATCH 055/132] post replacements: rename _was to old_ Rename the following post replacement attributes: * file_size_was -> old_file_size * file_ext_was -> old_file_ext * image_width_was -> old_image_width * image_height_was -> old_image_height * md5_was -> old_md5 In Rails 6.1, having attributes named `file_size` and `file_size_was` on the same model breaks things because it conflicts with Rails' dirty attribute tracking. --- app/models/post_replacement.rb | 12 ++++++------ app/policies/post_replacement_policy.rb | 4 ++-- app/views/post_replacements/index.html.erb | 10 +++++----- ...1219201007_rename_post_replacement_attributes.rb | 9 +++++++++ db/structure.sql | 13 +++++++------ .../functional/post_replacements_controller_test.rb | 4 ++-- test/unit/upload_service_test.rb | 10 +++++----- 7 files changed, 36 insertions(+), 26 deletions(-) create mode 100644 db/migrate/20201219201007_rename_post_replacement_attributes.rb diff --git a/app/models/post_replacement.rb b/app/models/post_replacement.rb index ae5a23174..290e54984 100644 --- a/app/models/post_replacement.rb +++ b/app/models/post_replacement.rb @@ -11,17 +11,17 @@ class PostReplacement < ApplicationRecord self.original_url = post.source self.tags = post.tag_string + " " + self.tags.to_s - self.file_ext_was = post.file_ext - self.file_size_was = post.file_size - self.image_width_was = post.image_width - self.image_height_was = post.image_height - self.md5_was = post.md5 + self.old_file_ext = post.file_ext + self.old_file_size = post.file_size + self.old_image_width = post.image_width + self.old_image_height = post.image_height + self.old_md5 = post.md5 end concerning :Search do class_methods do def search(params = {}) - q = search_attributes(params, :id, :created_at, :updated_at, :md5, :md5_was, :file_ext, :file_ext_was, :original_url, :replacement_url, :creator, :post) + q = search_attributes(params, :id, :created_at, :updated_at, :md5, :old_md5, :file_ext, :old_file_ext, :original_url, :replacement_url, :creator, :post) q.apply_default_order(params) end end diff --git a/app/policies/post_replacement_policy.rb b/app/policies/post_replacement_policy.rb index 0cec75193..2a03d6bee 100644 --- a/app/policies/post_replacement_policy.rb +++ b/app/policies/post_replacement_policy.rb @@ -12,8 +12,8 @@ class PostReplacementPolicy < ApplicationPolicy end def permitted_attributes_for_update - [:file_ext_was, :file_size_was, :image_width_was, :image_height_was, - :md5_was, :file_ext, :file_size, :image_width, :image_height, :md5, + [:old_file_ext, :old_file_size, :old_image_width, :old_image_height, + :old_md5, :file_ext, :file_size, :image_width, :image_height, :md5, :original_url, :replacement_url] end end diff --git a/app/views/post_replacements/index.html.erb b/app/views/post_replacements/index.html.erb index b0dd09c87..59a766341 100644 --- a/app/views/post_replacements/index.html.erb +++ b/app/views/post_replacements/index.html.erb @@ -29,10 +29,10 @@ <% end %> <% t.column "MD5" do |post_replacement| %> - <% if post_replacement.md5_was.present? && post_replacement.md5.present? %> + <% if post_replacement.old_md5.present? && post_replacement.md5.present? %>
      Original MD5
      -
      <%= post_replacement.md5_was %>
      +
      <%= post_replacement.old_md5 %>
      Replacement MD5
      <%= post_replacement.md5 %>
      @@ -40,12 +40,12 @@ <% end %> <% end %> <% t.column "Size" do |post_replacement| %> - <% if %i[image_width_was image_height_was file_size_was file_ext_was image_width image_height file_size file_ext].all? { |k| post_replacement[k].present? } %> + <% if %i[old_image_width old_image_height old_file_size old_file_ext image_width image_height file_size file_ext].all? { |k| post_replacement[k].present? } %>
      Original Size
      - <%= post_replacement.image_width_was %>x<%= post_replacement.image_height_was %> - (<%= post_replacement.file_size_was.to_s(:human_size, precision: 4) %>, <%= post_replacement.file_ext_was %>) + <%= post_replacement.old_image_width %>x<%= post_replacement.old_image_height %> + (<%= post_replacement.old_file_size.to_s(:human_size, precision: 4) %>, <%= post_replacement.old_file_ext %>)
      Replacement Size
      diff --git a/db/migrate/20201219201007_rename_post_replacement_attributes.rb b/db/migrate/20201219201007_rename_post_replacement_attributes.rb new file mode 100644 index 000000000..31584c920 --- /dev/null +++ b/db/migrate/20201219201007_rename_post_replacement_attributes.rb @@ -0,0 +1,9 @@ +class RenamePostReplacementAttributes < ActiveRecord::Migration[6.1] + def change + rename_column :post_replacements, :file_ext_was, :old_file_ext + rename_column :post_replacements, :file_size_was, :old_file_size + rename_column :post_replacements, :image_width_was, :old_image_width + rename_column :post_replacements, :image_height_was, :old_image_height + rename_column :post_replacements, :md5_was, :old_md5 + end +end diff --git a/db/structure.sql b/db/structure.sql index d85f9a64c..5f8a9b7da 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2736,11 +2736,11 @@ CREATE TABLE public.post_replacements ( replacement_url text NOT NULL, created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL, - file_ext_was character varying, - file_size_was integer, - image_width_was integer, - image_height_was integer, - md5_was character varying, + old_file_ext character varying, + old_file_size integer, + old_image_width integer, + old_image_height integer, + old_md5 character varying, file_ext character varying, file_size integer, image_width integer, @@ -7436,6 +7436,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200803022359'), ('20200816175151'), ('20201201211748'), -('20201213052805'); +('20201213052805'), +('20201219201007'); diff --git a/test/functional/post_replacements_controller_test.rb b/test/functional/post_replacements_controller_test.rb index e9021d1de..21525bcc1 100644 --- a/test/functional/post_replacements_controller_test.rb +++ b/test/functional/post_replacements_controller_test.rb @@ -47,14 +47,14 @@ class PostReplacementsControllerTest < ActionDispatch::IntegrationTest format: :json, id: @post_replacement.id, post_replacement: { - file_size_was: 23, + old_file_size: 23, file_size: 42 } } put_auth post_replacement_path(@post_replacement), @mod, params: params assert_response :success - assert_equal(23, @post_replacement.reload.file_size_was) + assert_equal(23, @post_replacement.reload.old_file_size) assert_equal(42, @post_replacement.file_size) end end diff --git a/test/unit/upload_service_test.rb b/test/unit/upload_service_test.rb index c8041ebfe..2828b30ab 100644 --- a/test/unit/upload_service_test.rb +++ b/test/unit/upload_service_test.rb @@ -287,11 +287,11 @@ class UploadServiceTest < ActiveSupport::TestCase should "preserve the old values" do as(@user) { subject.process! } - assert_equal(1500, @replacement.image_width_was) - assert_equal(1000, @replacement.image_height_was) - assert_equal(2000, @replacement.file_size_was) - assert_equal("jpg", @replacement.file_ext_was) - assert_equal(@old_md5, @replacement.md5_was) + assert_equal(1500, @replacement.old_image_width) + assert_equal(1000, @replacement.old_image_height) + assert_equal(2000, @replacement.old_file_size) + assert_equal("jpg", @replacement.old_file_ext) + assert_equal(@old_md5, @replacement.old_md5) end should "record the new values" do From 7708e2e08f9c3fe8a254ebb5a0e319b72fd21313 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 14:42:49 -0600 Subject: [PATCH 056/132] wikis: don't allow adding other names to artist wikis. Prevent users from adding other names to artist wikis. These should be added to the artist entry instead. --- app/models/wiki_page.rb | 8 ++++++++ app/views/wiki_pages/_form.html.erb | 5 ++++- test/unit/wiki_page_test.rb | 10 ++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3b0b28045..a60e10863 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -7,10 +7,12 @@ class WikiPage < ApplicationRecord before_save :normalize_other_names before_save :update_dtext_links, if: :dtext_links_changed? after_save :create_version + validates_uniqueness_of :title, :case_sensitive => false validates_presence_of :title validates_presence_of :body, :unless => -> { is_deleted? || other_names.present? } validate :validate_rename + validate :validate_other_names array_attribute :other_names has_one :tag, :foreign_key => "name", :primary_key => "title" @@ -124,6 +126,12 @@ class WikiPage < ApplicationRecord end end + def validate_other_names + if other_names.present? && tag&.artist? + errors.add(:base, "An artist wiki can't have other names") + end + end + def revert_to(version) if id != version.wiki_page_id raise RevertError.new("You cannot revert to a previous version of another wiki page.") diff --git a/app/views/wiki_pages/_form.html.erb b/app/views/wiki_pages/_form.html.erb index 8f5616dc7..2ff231fd8 100644 --- a/app/views/wiki_pages/_form.html.erb +++ b/app/views/wiki_pages/_form.html.erb @@ -3,7 +3,10 @@ <%= edit_form_for(@wiki_page, url: wiki_page_path(@wiki_page.id)) do |f| %> <%= f.input :title, error: false, input_html: { data: { autocomplete: "tag" } }, hint: "Change to rename this wiki page. Update any wikis linking to this page first." %> - <%= f.input :other_names_string, as: :text, input_html: { size: "30x1" }, label: "Other names (#{link_to_wiki "help", "help:translated_tags"})".html_safe, hint: "Names used for this tag on other sites such as Pixiv. Separate with spaces." %> + + <% if !@wiki_page.tag&.artist? || @wiki_page.other_names.present? %> + <%= f.input :other_names_string, as: :text, input_html: { size: "30x1" }, label: "Other names (#{link_to_wiki "help", "help:translated_tags"})".html_safe, hint: "Names used for this tag on other sites such as Pixiv. Separate with spaces." %> + <% end %> <%= f.input :body, as: :dtext %> diff --git a/test/unit/wiki_page_test.rb b/test/unit/wiki_page_test.rb index bd52fc719..41b814a8a 100644 --- a/test/unit/wiki_page_test.rb +++ b/test/unit/wiki_page_test.rb @@ -77,5 +77,15 @@ class WikiPageTest < ActiveSupport::TestCase assert_equal(0, @wiki_page.dtext_links.size) end end + + context "with other names" do + should "not allow artist wikis to have other names" do + tag = create(:artist_tag) + wiki = build(:wiki_page, title: tag.name, other_names: ["blah"]) + + assert_equal(false, wiki.valid?) + assert_equal(["An artist wiki can't have other names"], wiki.errors[:base]) + end + end end end From a129eb4251f5a49e123c632467e081b81980c5ff Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 17:51:11 -0600 Subject: [PATCH 057/132] wikis: force wiki names to follow same rules as tag names. Don't allow wiki pages to have invalid names. This incidentally means that you can't create wiki pages for pools. For example, you can't create a wiki titled "pool:almost_heart-warming". This is not a valid tag name, so it's not a valid wiki name either. This was done in a handful of cases to translate Pixiv tags to Danbooru pools (see: ) Also fix it so that titles are normalized before validation, not before save. --- app/models/wiki_page.rb | 12 ++++------ test/functional/wiki_pages_controller_test.rb | 7 ------ test/unit/wiki_page_test.rb | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index a60e10863..79922b98c 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -3,14 +3,13 @@ class WikiPage < ApplicationRecord META_WIKIS = ["list_of_", "tag_group:", "pool_group:", "howto:", "about:", "help:", "template:"] - before_save :normalize_title - before_save :normalize_other_names + before_validation :normalize_title + before_validation :normalize_other_names before_save :update_dtext_links, if: :dtext_links_changed? after_save :create_version - validates_uniqueness_of :title, :case_sensitive => false - validates_presence_of :title - validates_presence_of :body, :unless => -> { is_deleted? || other_names.present? } + validates :title, tag_name: true, presence: true, uniqueness: true, if: :title_changed? + validates :body, presence: true, unless: -> { is_deleted? || other_names.present? } validate :validate_rename validate :validate_other_names @@ -149,8 +148,7 @@ class WikiPage < ApplicationRecord end def self.normalize_title(title) - return if title.blank? - title.downcase.delete_prefix("~").gsub(/[[:space:]]+/, "_").gsub(/__/, "_").gsub(/\A_|_\z/, "") + title.to_s.downcase.delete_prefix("~").gsub(/[[:space:]]+/, "_").gsub(/__/, "_").gsub(/\A_|_\z/, "") end def normalize_title diff --git a/test/functional/wiki_pages_controller_test.rb b/test/functional/wiki_pages_controller_test.rb index 5a4ad9c2b..0c8ae20b3 100644 --- a/test/functional/wiki_pages_controller_test.rb +++ b/test/functional/wiki_pages_controller_test.rb @@ -109,13 +109,6 @@ class WikiPagesControllerTest < ActionDispatch::IntegrationTest assert_response 404 end - should "render for a negated tag" do - as(@user) { @wiki_page.update(title: "-aaa") } - - get wiki_page_path(@wiki_page.id) - assert_redirected_to wiki_page_path(@wiki_page.title) - end - should "work for a title containing dots" do as(@user) { create(:wiki_page, title: "...") } diff --git a/test/unit/wiki_page_test.rb b/test/unit/wiki_page_test.rb index 41b814a8a..32391cfb8 100644 --- a/test/unit/wiki_page_test.rb +++ b/test/unit/wiki_page_test.rb @@ -78,6 +78,29 @@ class WikiPageTest < ActiveSupport::TestCase end end + context "during title validation" do + # these values are allowed because they're normalized first + should allow_value(" foo ").for(:title).on(:create) + should allow_value("~foo").for(:title).on(:create) + should allow_value("_foo").for(:title).on(:create) + should allow_value("foo_").for(:title).on(:create) + should allow_value("foo__bar").for(:title).on(:create) + should allow_value("FOO").for(:title).on(:create) + should allow_value("foo bar").for(:title).on(:create) + + should_not allow_value("").for(:title).on(:create) + should_not allow_value("___").for(:title).on(:create) + should_not allow_value("-foo").for(:title).on(:create) + should_not allow_value("/foo").for(:title).on(:create) + should_not allow_value("foo*bar").for(:title).on(:create) + should_not allow_value("foo,bar").for(:title).on(:create) + should_not allow_value("foo\abar").for(:title).on(:create) + should_not allow_value("café").for(:title).on(:create) + should_not allow_value("東方").for(:title).on(:create) + should_not allow_value("FAV:blah").for(:title).on(:create) + should_not allow_value("X"*171).for(:title).on(:create) + end + context "with other names" do should "not allow artist wikis to have other names" do tag = create(:artist_tag) From 9de7a07af7d6e29e17bfac3b9fae17507fc6b949 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 19 Dec 2020 22:46:40 -0600 Subject: [PATCH 058/132] /status: fix blank distro version field. The `source` command is a bash-ism and doesn't work in a strictly POSIX shell like dash, which is the /bin/sh on Debian/Ubuntu. Use `.` instead. https://en.wikipedia.org/wiki/Dot_(command) --- app/logical/server_status.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/logical/server_status.rb b/app/logical/server_status.rb index 197582bf7..e3a903230 100644 --- a/app/logical/server_status.rb +++ b/app/logical/server_status.rb @@ -46,7 +46,7 @@ class ServerStatus end def distro_version - `source /etc/os-release; echo "$NAME $VERSION"`.chomp + `. /etc/os-release; echo "$NAME $VERSION"`.chomp end def libvips_version From 28926c2332b7fbe1d8e67012b4e5b72f79d631bb Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 20 Dec 2020 00:39:05 -0600 Subject: [PATCH 059/132] autocomplete: remove old autocomplete endpoints. Remove /tag/autocomplete.json and /saved_searches/labels.json. --- app/controllers/saved_searches_controller.rb | 6 - app/controllers/tags_controller.rb | 12 -- app/logical/tag_autocomplete.rb | 113 ----------------- app/models/saved_search.rb | 13 -- config/routes.rb | 12 +- .../saved_searches_controller_test.rb | 7 -- test/functional/tags_controller_test.rb | 14 --- test/unit/saved_search_test.rb | 13 -- test/unit/tag_autocomplete_test.rb | 115 ------------------ 9 files changed, 2 insertions(+), 303 deletions(-) delete mode 100644 app/logical/tag_autocomplete.rb delete mode 100644 test/unit/tag_autocomplete_test.rb diff --git a/app/controllers/saved_searches_controller.rb b/app/controllers/saved_searches_controller.rb index a946e34c8..b7a163285 100644 --- a/app/controllers/saved_searches_controller.rb +++ b/app/controllers/saved_searches_controller.rb @@ -6,12 +6,6 @@ class SavedSearchesController < ApplicationController respond_with(@saved_searches) end - def labels - authorize SavedSearch - @labels = SavedSearch.search_labels(CurrentUser.id, params[:search]).take(params[:limit].to_i || 10) - respond_with(@labels) - end - def create @saved_search = authorize SavedSearch.new(user: CurrentUser.user, **permitted_attributes(SavedSearch)) @saved_search.save diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 9fcf23fd7..8deac803c 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -12,18 +12,6 @@ class TagsController < ApplicationController respond_with(@tags) end - def autocomplete - if CurrentUser.is_builder? - # limit rollout - @tags = TagAutocomplete.search(params[:search][:name_matches]) - else - @tags = Tag.names_matches_with_aliases(params[:search][:name_matches], params.fetch(:limit, 10).to_i) - end - - # XXX - respond_with(@tags.map(&:attributes)) - end - def show @tag = authorize Tag.find(params[:id]) respond_with(@tag) diff --git a/app/logical/tag_autocomplete.rb b/app/logical/tag_autocomplete.rb deleted file mode 100644 index ba34e658c..000000000 --- a/app/logical/tag_autocomplete.rb +++ /dev/null @@ -1,113 +0,0 @@ -module TagAutocomplete - module_function - - PREFIX_BOUNDARIES = "(_/:;-" - LIMIT = 10 - - Result = Struct.new(:name, :post_count, :category, :antecedent_name, :source) do - include ActiveModel::Serializers::JSON - include ActiveModel::Serializers::Xml - - def attributes - (members + [:weight]).map { |x| [x.to_s, send(x)] }.to_h - end - - def weight - case source - when :exact then 1.0 - when :prefix then 0.8 - when :alias then 0.2 - when :correct then 0.1 - end - end - end - - def search(query) - query = Tag.normalize_name(query) - - count_sort( - search_exact(query, 8) + - search_prefix(query, 4) + - search_correct(query, 2) + - search_aliases(query, 3) - ) - end - - def count_sort(words) - words.uniq(&:name).sort_by do |x| - x.post_count * x.weight - end.reverse.slice(0, LIMIT) - end - - def search_exact(query, n = 4) - Tag - .where_like(:name, query + "*") - .where("post_count > 0") - .order("post_count desc") - .limit(n) - .pluck(:name, :post_count, :category) - .map {|row| Result.new(*row, nil, :exact)} - end - - def search_correct(query, n = 2) - if query.size <= 3 - return [] - end - - Tag - .where("name % ?", query) - .where("abs(length(name) - ?) <= 3", query.size) - .where_like(:name, query[0] + "*") - .where("post_count > 0") - .order(Arel.sql("similarity(name, #{Tag.connection.quote(query)}) DESC")) - .limit(n) - .pluck(:name, :post_count, :category) - .map {|row| Result.new(*row, nil, :correct)} - end - - def search_prefix(query, n = 3) - if query.size >= 5 - return [] - end - - if query.size <= 1 - return [] - end - - if query =~ /[-_()]/ - return [] - end - - if query.size >= 3 - min_post_count = 0 - else - min_post_count = 5_000 - n += 2 - end - - regexp = "([a-z0-9])[a-z0-9']*($|[^a-z0-9']+)" - Tag - .where('regexp_replace(name, ?, ?, ?) like ?', regexp, '\1', 'g', query.to_escaped_for_sql_like + '%') - .where("post_count > ?", min_post_count) - .where("post_count > 0") - .order("post_count desc") - .limit(n) - .pluck(:name, :post_count, :category) - .map {|row| Result.new(*row, nil, :prefix)} - end - - def search_aliases(query, n = 10) - wildcard_name = query + "*" - TagAlias - .select("tags.name, tags.post_count, tags.category, tag_aliases.antecedent_name") - .joins("INNER JOIN tags ON tags.name = tag_aliases.consequent_name") - .where_like(:antecedent_name, wildcard_name) - .active - .where_not_like("tags.name", wildcard_name) - .where("tags.post_count > 0") - .order("tags.post_count desc") - .limit(n) - .pluck(:name, :post_count, :category, :antecedent_name) - .map {|row| Result.new(*row, :alias)} - end -end diff --git a/app/models/saved_search.rb b/app/models/saved_search.rb index e3dad2dc0..fbe92a248 100644 --- a/app/models/saved_search.rb +++ b/app/models/saved_search.rb @@ -71,19 +71,6 @@ class SavedSearch < ApplicationRecord all_labels.select { |ss| ss.label.ilike?(label) }.map(&:label) end - def search_labels(user_id, params) - labels = labels_for(user_id) - - if params[:label].present? - query = Regexp.escape(params[:label]).gsub("\\*", ".*") - query = ".*#{query}.*" unless query.include?("*") - query = /\A#{query}\z/ - labels = labels.grep(query) - end - - labels - end - def labels_for(user_id) SavedSearch .where(user_id: user_id) diff --git a/config/routes.rb b/config/routes.rb index 6244fed13..13675fad2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -215,21 +215,13 @@ Rails.application.routes.draw do resource :related_tag, :only => [:show, :update] resources :recommended_posts, only: [:index] resources :robots, only: [:index] - resources :saved_searches, :except => [:show] do - collection do - get :labels - end - end + resources :saved_searches, :except => [:show] resource :session, only: [:new, :create, :destroy] do get :sign_out, on: :collection end resource :source, :only => [:show] resource :status, only: [:show], controller: "status" - resources :tags do - collection do - get :autocomplete - end - end + resources :tags resources :tag_aliases, only: [:show, :index, :destroy] resources :tag_implications, only: [:show, :index, :destroy] resources :uploads do diff --git a/test/functional/saved_searches_controller_test.rb b/test/functional/saved_searches_controller_test.rb index bc390dd08..8039d0689 100644 --- a/test/functional/saved_searches_controller_test.rb +++ b/test/functional/saved_searches_controller_test.rb @@ -16,13 +16,6 @@ class SavedSearchesControllerTest < ActionDispatch::IntegrationTest end end - context "labels action" do - should "render" do - get_auth labels_saved_searches_path, @user, as: :json - assert_response :success - end - end - context "create action" do should "render" do post_auth saved_searches_path, @user, params: { saved_search: { query: "bkub", label_string: "artist" }} diff --git a/test/functional/tags_controller_test.rb b/test/functional/tags_controller_test.rb index 797412d90..f00bfb7ab 100644 --- a/test/functional/tags_controller_test.rb +++ b/test/functional/tags_controller_test.rb @@ -76,20 +76,6 @@ class TagsControllerTest < ActionDispatch::IntegrationTest end end - context "autocomplete action" do - should "render" do - get autocomplete_tags_path, params: { search: { name_matches: "t" }, format: :json } - assert_response :success - end - - should "respect the only param" do - get autocomplete_tags_path, params: { search: { name_matches: "t", only: "name" }, format: :json } - - assert_response :success - assert_equal "touhou", response.parsed_body.first["name"] - end - end - context "show action" do should "render" do get tag_path(@tag) diff --git a/test/unit/saved_search_test.rb b/test/unit/saved_search_test.rb index 75cbcf8f6..d114b90e9 100644 --- a/test/unit/saved_search_test.rb +++ b/test/unit/saved_search_test.rb @@ -44,19 +44,6 @@ class SavedSearchTest < ActiveSupport::TestCase end end - context ".search_labels" do - setup do - FactoryBot.create(:tag_alias, antecedent_name: "bbb", consequent_name: "ccc", creator: @user) - FactoryBot.create(:saved_search, user: @user, label_string: "blah", query: "aaa") - FactoryBot.create(:saved_search, user: @user, label_string: "blahbling", query: "CCC BBB AAA") - FactoryBot.create(:saved_search, user: @user, label_string: "qux", query: " aaa bbb ccc ") - end - - should "fetch the queries used by a user for a label" do - assert_equal(%w(blah blahbling), SavedSearch.search_labels(@user.id, label: "blah")) - end - end - context ".post_ids_for" do context "with a label" do setup do diff --git a/test/unit/tag_autocomplete_test.rb b/test/unit/tag_autocomplete_test.rb deleted file mode 100644 index f37f930ce..000000000 --- a/test/unit/tag_autocomplete_test.rb +++ /dev/null @@ -1,115 +0,0 @@ -require 'test_helper' - -class TagAutocompleteTest < ActiveSupport::TestCase - subject { TagAutocomplete } - - context "#search" do - should "be case insensitive" do - create(:tag, name: "abcdef", post_count: 1) - assert_equal(["abcdef"], subject.search("A").map(&:name)) - end - - should "not return duplicates" do - create(:tag, name: "red_eyes", post_count: 5001) - assert_equal(%w[red_eyes], subject.search("re").map(&:name)) - end - end - - context "#search_exact" do - setup do - @tags = [ - create(:tag, name: "abcdef", post_count: 1), - create(:tag, name: "abczzz", post_count: 2), - create(:tag, name: "abcyyy", post_count: 0), - create(:tag, name: "bbbbbb") - ] - end - - should "find the tags" do - expected = [ - @tags[1], - @tags[0] - ].map(&:name) - assert_equal(expected, subject.search_exact("abc", 3).map(&:name)) - end - end - - context "#search_correct" do - setup do - CurrentUser.stubs(:id).returns(1) - - @tags = [ - create(:tag, name: "abcde", post_count: 1), - create(:tag, name: "abcdz", post_count: 2), - - # one char mismatch - create(:tag, name: "abcez", post_count: 2), - - # too long - create(:tag, name: "abcdefghijk", post_count: 2), - - # wrong prefix - create(:tag, name: "bbcdef", post_count: 2), - - # zero post count - create(:tag, name: "abcdy", post_count: 0), - - # completely different - create(:tag, name: "bbbbb") - ] - end - - should "find the tags" do - expected = [ - @tags[0], - @tags[1], - @tags[2] - ].map(&:name) - assert_equal(expected, subject.search_correct("abcd", 3).map(&:name)) - end - end - - context "#search_prefix" do - setup do - @tags = [ - create(:tag, name: "abcdef", post_count: 1), - create(:tag, name: "alpha_beta_cat", post_count: 2), - create(:tag, name: "alpha_beta_dat", post_count: 0), - create(:tag, name: "alpha_beta_(cane)", post_count: 2), - create(:tag, name: "alpha_beta/cane", post_count: 2) - ] - end - - should "find the tags" do - expected = [ - @tags[1], - @tags[3], - @tags[4] - ].map(&:name) - assert_equal(expected, subject.search_prefix("abc", 3).map(&:name)) - end - end - - context "#search_aliases" do - setup do - @user = create(:user) - @tags = [ - create(:tag, name: "/abc", post_count: 0), - create(:tag, name: "abcdef", post_count: 1), - create(:tag, name: "zzzzzz", post_count: 1) - ] - as(@user) do - @aliases = [ - create(:tag_alias, antecedent_name: "/abc", consequent_name: "abcdef", status: "active") - ] - end - end - - should "find the tags" do - results = subject.search_aliases("/abc", 3) - assert_equal(1, results.size) - assert_equal("abcdef", results[0].name) - assert_equal("/abc", results[0].antecedent_name) - end - end -end From 3ad4beac02693932bc77cabfa38a2ff162295bc8 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 20 Dec 2020 01:27:48 -0600 Subject: [PATCH 060/132] autocomplete: fix exception when completing unsupported metatags. --- app/logical/autocomplete_service.rb | 2 ++ test/unit/autocomplete_service_test.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index f75101098..23bb33f92 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -148,6 +148,8 @@ class AutocompleteService autocomplete_saved_search_label(value) when *STATIC_METATAGS.keys autocomplete_static_metatag(metatag, value) + else + [] end results.map do |result| diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index e0463f36e..d57ef52ce 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -150,6 +150,14 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_equals(%w[touhou], "touhuo", :tag_query) end + should "ignore unsupported metatags" do + assert_autocomplete_equals([], "date:2020", :tag_query) + assert_autocomplete_equals([], "score:20", :tag_query) + assert_autocomplete_equals([], "favcount:>20", :tag_query) + assert_autocomplete_equals([], "age:<1w", :tag_query) + assert_autocomplete_equals([], "limit:200", :tag_query) + end + should "autocomplete static metatags" do assert_autocomplete_equals(["status:active"], "status:act", :tag_query) assert_autocomplete_equals(["parent:active"], "parent:act", :tag_query) From 48ff7c42cd644d8d35b37365c88bcf4653873c12 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 20 Dec 2020 01:33:10 -0600 Subject: [PATCH 061/132] autocomplete: bump opensearch description version. Fix browsers still using the old autocomplete endpoint for opensearch searches (searches performed in the browser toolbar). --- app/views/layouts/default.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index 97174b523..c79fa8acb 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -7,7 +7,7 @@ <%= render "meta_links", collection: @current_item %> <%= tag.link rel: "canonical", href: canonical_url %> - <%= tag.link rel: "search", type: "application/opensearchdescription+xml", href: opensearch_url(format: :xml, version: 1), title: "Search posts" %> + <%= tag.link rel: "search", type: "application/opensearchdescription+xml", href: opensearch_url(format: :xml, version: 2), title: "Search posts" %> <%= csrf_meta_tag %> <% unless CurrentUser.enable_desktop_mode? %> From 2423c8a4471e6f9307985d66a88316c180ba2b65 Mon Sep 17 00:00:00 2001 From: nonamethanks Date: Mon, 21 Dec 2020 15:28:28 +0100 Subject: [PATCH 062/132] Weibo: use proxy for upload previews --- app/logical/sources/strategies/weibo.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/logical/sources/strategies/weibo.rb b/app/logical/sources/strategies/weibo.rb index 4daf5e69e..e953c9ff4 100644 --- a/app/logical/sources/strategies/weibo.rb +++ b/app/logical/sources/strategies/weibo.rb @@ -90,6 +90,10 @@ module Sources image_urls.map { |img| img.gsub(%r{.cn/\w+/(\w+)}, '.cn/orj360/\1') } end + def headers + { "Referer" => "https://weibo.com" } + end + def page_url if api_response.present? artist_id = api_response["user"]["id"] From efb836ac028d49082fa2d7d5879d7de51498eed3 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 03:23:19 -0600 Subject: [PATCH 063/132] wikis: normalize Unicode characters in wiki bodies. * Introduce an abstraction for normalizing attributes. Very loosely modeled after https://github.com/fnando/normalize_attributes. * Normalize wiki bodies to Unicode NFC form. * Normalize Unicode space characters in wiki bodies (strip zero width spaces, normalize line endings to CRLF, normalize Unicode spaces to ASCII spaces). * Trim spaces from the start and end of wiki page bodies. This may cause wiki page diffs to show spaces being removed even when the user didn't explicitly remove the spaces themselves. --- app/logical/concerns/normalizable.rb | 18 +++++++++++++++++ app/models/application_record.rb | 1 + app/models/wiki_page.rb | 8 +++----- config/initializers/core_extensions.rb | 13 ++++++++++++ test/unit/string_test.rb | 28 ++++++++++++++++++++++++++ test/unit/wiki_page_test.rb | 13 ++++++++++++ 6 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 app/logical/concerns/normalizable.rb diff --git a/app/logical/concerns/normalizable.rb b/app/logical/concerns/normalizable.rb new file mode 100644 index 000000000..7cad97e61 --- /dev/null +++ b/app/logical/concerns/normalizable.rb @@ -0,0 +1,18 @@ +module Normalizable + extend ActiveSupport::Concern + + class_methods do + def normalize(attribute, method_name) + define_method("#{attribute}=") do |value| + normalized_value = self.class.send(method_name, value) + super(normalized_value) + end + end + + private + + def normalize_text(text) + text.unicode_normalize(:nfc).normalize_whitespace.strip + end + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index e162b39dd..fc8391890 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -3,6 +3,7 @@ class ApplicationRecord < ActiveRecord::Base include Deletable include Mentionable + include Normalizable extend HasBitFlags extend Searchable diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 79922b98c..236ba0410 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -3,11 +3,13 @@ class WikiPage < ApplicationRecord META_WIKIS = ["list_of_", "tag_group:", "pool_group:", "howto:", "about:", "help:", "template:"] - before_validation :normalize_title before_validation :normalize_other_names before_save :update_dtext_links, if: :dtext_links_changed? after_save :create_version + normalize :title, :normalize_title + normalize :body, :normalize_text + validates :title, tag_name: true, presence: true, uniqueness: true, if: :title_changed? validates :body, presence: true, unless: -> { is_deleted? || other_names.present? } validate :validate_rename @@ -151,10 +153,6 @@ class WikiPage < ApplicationRecord title.to_s.downcase.delete_prefix("~").gsub(/[[:space:]]+/, "_").gsub(/__/, "_").gsub(/\A_|_\z/, "") end - def normalize_title - self.title = WikiPage.normalize_title(title) - end - def normalize_other_names self.other_names = other_names.map { |name| WikiPage.normalize_other_name(name) }.uniq end diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index ef7dba48d..2456c6b9a 100644 --- a/config/initializers/core_extensions.rb +++ b/config/initializers/core_extensions.rb @@ -41,6 +41,19 @@ module Danbooru pattern = Regexp.escape(pattern).gsub(/\\\*/, ".*") match?(/\A#{pattern}\z/i) end + + def normalize_whitespace + # Normalize various horizontal space characters to ASCII space. + text = gsub(/\p{Zs}|\t/, " ") + + # Strip various zero width space characters. + text = text.gsub(/[\u180E\u200B\u200C\u200D\u2060\uFEFF]/, "") + + # Normalize various line ending characters to CRLF. + text = text.gsub(/\r?\n|\r|\v|\f|\u0085|\u2028|\u2029/, "\r\n") + + text + end end end end diff --git a/test/unit/string_test.rb b/test/unit/string_test.rb index 47224ba77..247c637e0 100644 --- a/test/unit/string_test.rb +++ b/test/unit/string_test.rb @@ -14,4 +14,32 @@ class StringTest < ActiveSupport::TestCase assert_equal('%*%', '*\**'.to_escaped_for_sql_like) end end + + context "String#normalize_whitespace" do + should "normalize unicode spaces" do + assert_equal("foo bar", "foo bar".normalize_whitespace) + assert_equal("foo bar", "foo\u00A0bar".normalize_whitespace) + assert_equal("foo bar", "foo\u3000bar".normalize_whitespace) + end + + should "strip zero width characters" do + assert_equal("foobar", "foo\u180Ebar".normalize_whitespace) + assert_equal("foobar", "foo\u200Bbar".normalize_whitespace) + assert_equal("foobar", "foo\u200Cbar".normalize_whitespace) + assert_equal("foobar", "foo\u200Dbar".normalize_whitespace) + assert_equal("foobar", "foo\u2060bar".normalize_whitespace) + assert_equal("foobar", "foo\uFEFFbar".normalize_whitespace) + end + + should "normalize line endings" do + assert_equal("foo\r\nbar", "foo\r\nbar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\nbar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\rbar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\vbar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\fbar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\u0085bar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\u2028bar".normalize_whitespace) + assert_equal("foo\r\nbar", "foo\u2029bar".normalize_whitespace) + end + end end diff --git a/test/unit/wiki_page_test.rb b/test/unit/wiki_page_test.rb index 32391cfb8..150561e3b 100644 --- a/test/unit/wiki_page_test.rb +++ b/test/unit/wiki_page_test.rb @@ -78,6 +78,19 @@ class WikiPageTest < ActiveSupport::TestCase end end + context "the wiki body" do + should "be normalized to NFC" do + # \u00E9: é; \u0301: acute accent + @wiki = create(:wiki_page, body: "Poke\u0301mon") + assert_equal("Pok\u00E9mon", @wiki.body) + end + + should "normalize line endings and trim spaces" do + @wiki = create(:wiki_page, body: " foo\nbar\n") + assert_equal("foo\r\nbar", @wiki.body) + end + end + context "during title validation" do # these values are allowed because they're normalized first should allow_value(" foo ").for(:title).on(:create) From 6ac988271116296ba675020327fb226a70639e60 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 18:48:01 -0600 Subject: [PATCH 064/132] newrelic: log country of each request in newrelic. Log the country of each HTTP request in NewRelic. Uses the CF-IPCountry header set by Cloudflare. --- app/logical/danbooru_logger.rb | 9 ++++++++- test/unit/session_loader_test.rb | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/logical/danbooru_logger.rb b/app/logical/danbooru_logger.rb index 01422f727..09f763e7e 100644 --- a/app/logical/danbooru_logger.rb +++ b/app/logical/danbooru_logger.rb @@ -24,7 +24,14 @@ class DanbooruLogger def self.add_session_attributes(request, session, user) request_params = request.parameters.with_indifferent_access.except(:controller, :action) session_params = session.to_h.with_indifferent_access.slice(:session_id, :started_at) - user_params = { id: user&.id, name: user&.name, level: user&.level_string, ip: request.remote_ip, safe_mode: CurrentUser.safe_mode? } + user_params = { + id: user&.id, + name: user&.name, + level: user&.level_string, + ip: request.remote_ip, + country: request.headers["CF-IPCountry"], + safe_mode: CurrentUser.safe_mode? + } add_attributes("request.params", request_params) add_attributes("session.params", session_params) diff --git a/test/unit/session_loader_test.rb b/test/unit/session_loader_test.rb index 075eca261..984ea6cf1 100644 --- a/test/unit/session_loader_test.rb +++ b/test/unit/session_loader_test.rb @@ -10,6 +10,7 @@ class SessionLoaderTest < ActiveSupport::TestCase @request.stubs(:cookie_jar).returns({}) @request.stubs(:parameters).returns({}) @request.stubs(:session).returns({}) + @request.stubs(:headers).returns({}) SessionLoader.any_instance.stubs(:initialize_session_cookies) end From 6c99bbbf47feacce0351da6dfc24a637d8477499 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 18:57:04 -0600 Subject: [PATCH 065/132] posts: limit sources to 1200 chars long. The longest sources on Danbooru are DeviantArt wixmp.com sources, which max out at ~900 chars. --- app/models/post.rb | 1 + test/unit/post_test.rb | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/app/models/post.rb b/app/models/post.rb index 9fd75b7ad..58e7079d3 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -20,6 +20,7 @@ class Post < ApplicationRecord before_validation :remove_parent_loops validates_uniqueness_of :md5, :on => :create, message: ->(obj, data) { "duplicate: #{Post.find_by_md5(obj.md5).id}"} validates_inclusion_of :rating, in: %w(s q e), message: "rating must be s, q, or e" + validates :source, length: { maximum: 1200 } validate :added_tags_are_valid validate :removed_tags_are_valid validate :has_artist_tag diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index ee470de05..c2fa690fa 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -1076,6 +1076,13 @@ class PostTest < ActiveSupport::TestCase @post.update(:tag_string => "source:https://img18.pixiv.net/img/evazion/14901720.png") assert_equal(14901720, @post.pixiv_id) end + + should "validate the max source length" do + @post.update(source: "X"*1201) + + assert_equal(false, @post.valid?) + assert_equal(["is too long (maximum is 1200 characters)"], @post.errors[:source]) + end end context "of" do From 906430b9839ac08f36bc86eb40a37818430d07b3 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 19:16:44 -0600 Subject: [PATCH 066/132] config: add option for customizing session cookie name. Fixes getting logged out when you visited Testbooru because of Testbooru's session cookies clobbering Danbooru's session cookies. --- config/danbooru_default_config.rb | 6 ++++++ config/initializers/session_store.rb | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 0d2cc3321..0bb6d3459 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -46,6 +46,12 @@ module Danbooru "DanbooruBot" end + # The name of the cookie that stores the current user's login session. + # Changing this will force all users to login again. + def session_cookie_name + "_danbooru2_session" + end + def source_code_url "https://github.com/danbooru/danbooru" end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 6cb8f0eb3..2bbb72e68 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,3 +1,11 @@ # Be sure to restart your server when you modify this file. -Rails.application.config.session_store :cookie_store, key: '_danbooru2_session', domain: :all, tld_length: 2, same_site: :lax, secure: Rails.env.production? +# https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html +Rails.application.config.session_store( + :cookie_store, + key: Danbooru.config.session_cookie_name, + domain: :all, + tld_length: 2, + same_site: :lax, + secure: Rails.env.production? +) From f3880569e15a8e7cbf7a2418b0ff2a812626f2c6 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 20:32:27 -0600 Subject: [PATCH 067/132] rails: update settings to 6.1 defaults. Most of the new settings aren't relevant to us. We do have to fix some tests to work around a Rails bug. `assert_enqueued_email_with` uses the wrong queue, so we have to specify it explicitly. This is fixed in Rails HEAD but not yet released. --- config/application.rb | 2 +- .../new_framework_defaults_6_1.rb | 63 ------------------- test/functional/emails_controller_test.rb | 4 +- .../password_resets_controller_test.rb | 2 +- test/functional/users_controller_test.rb | 2 +- 5 files changed, 5 insertions(+), 68 deletions(-) delete mode 100644 config/initializers/new_framework_defaults_6_1.rb diff --git a/config/application.rb b/config/application.rb index 5f2e046ac..85ebe5db0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -35,7 +35,7 @@ module Danbooru config.app_generators.scaffold_controller :responders_controller # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 6.0 + config.load_defaults 6.1 config.active_record.schema_format = :sql config.encoding = "utf-8" config.filter_parameters += [:password, :password_confirmation, :password_hash, :api_key] diff --git a/config/initializers/new_framework_defaults_6_1.rb b/config/initializers/new_framework_defaults_6_1.rb deleted file mode 100644 index 629888deb..000000000 --- a/config/initializers/new_framework_defaults_6_1.rb +++ /dev/null @@ -1,63 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file contains migration options to ease your Rails 6.1 upgrade. -# -# Once upgraded flip defaults one by one to migrate to the new default. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. - -# Support for inversing belongs_to -> has_many Active Record associations. -# Rails.application.config.active_record.has_many_inversing = true - -# Track Active Storage variants in the database. -# Rails.application.config.active_storage.track_variants = true - -# Apply random variation to the delay when retrying failed jobs. -# Rails.application.config.active_job.retry_jitter = 0.15 - -# Stop executing `after_enqueue`/`after_perform` callbacks if -# `before_enqueue`/`before_perform` respectively halts with `throw :abort`. -# Rails.application.config.active_job.skip_after_callbacks_if_terminated = true - -# Specify cookies SameSite protection level: either :none, :lax, or :strict. -# -# This change is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 6.1. -# Rails.application.config.action_dispatch.cookies_same_site_protection = :lax - -# Generate CSRF tokens that are encoded in URL-safe Base64. -# -# This change is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 6.1. -# Rails.application.config.action_controller.urlsafe_csrf_tokens = true - -# Specify whether `ActiveSupport::TimeZone.utc_to_local` returns a time with an -# UTC offset or a UTC time. -# ActiveSupport.utc_to_local_returns_utc_offset_times = true - -# Change the default HTTP status code to `308` when redirecting non-GET/HEAD -# requests to HTTPS in `ActionDispatch::SSL` middleware. -# Rails.application.config.action_dispatch.ssl_default_redirect_status = 308 - -# Use new connection handling API. For most applications this won't have any -# effect. For applications using multiple databases, this new API provides -# support for granular connection swapping. -# Rails.application.config.active_record.legacy_connection_handling = false - -# Make `form_with` generate non-remote forms by default. -# Rails.application.config.action_view.form_with_generates_remote_forms = false - -# Set the default queue name for the analysis job to the queue adapter default. -# Rails.application.config.active_storage.queues.analysis = nil - -# Set the default queue name for the purge job to the queue adapter default. -# Rails.application.config.active_storage.queues.purge = nil - -# Set the default queue name for the incineration job to the queue adapter default. -# Rails.application.config.action_mailbox.queues.incineration = nil - -# Set the default queue name for the routing job to the queue adapter default. -# Rails.application.config.action_mailbox.queues.routing = nil - -# Set the default queue name for the mail deliver job to the queue adapter default. -# Rails.application.config.action_mailer.deliver_later_queue_name = nil diff --git a/test/functional/emails_controller_test.rb b/test/functional/emails_controller_test.rb index 4da9bb9a5..8d646a382 100644 --- a/test/functional/emails_controller_test.rb +++ b/test/functional/emails_controller_test.rb @@ -88,7 +88,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to(settings_path) assert_equal("abc@ogres.net", @user.reload.email_address.address) assert_equal(false, @user.email_address.is_verified) - assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user] + assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default" end should "create a new address" do @@ -101,7 +101,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to(settings_path) assert_equal("abc@ogres.net", @user.reload.email_address.address) assert_equal(false, @user.reload.email_address.is_verified) - assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user] + assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default" end end diff --git a/test/functional/password_resets_controller_test.rb b/test/functional/password_resets_controller_test.rb index 9455ec67d..16c6c8588 100644 --- a/test/functional/password_resets_controller_test.rb +++ b/test/functional/password_resets_controller_test.rb @@ -15,7 +15,7 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest post password_reset_path, params: { user: { name: @user.name } } assert_redirected_to new_session_path - assert_enqueued_email_with UserMailer, :password_reset, args: [@user] + assert_enqueued_email_with UserMailer, :password_reset, args: [@user], queue: "default" end should "should fail if the user doesn't have a verified email address" do diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 5c2e2b583..fe4353683 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -268,7 +268,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal("xxx", User.last.name) assert_equal(User.last, User.last.authenticate_password("xxxxx1")) assert_equal("webmaster@danbooru.donmai.us", User.last.email_address.address) - assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last] + assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last], queue: "default" end should "not create a user with an invalid email" do From 0be9c8dc8b40c59636e7dc636fd750ef811abed9 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 21:05:19 -0600 Subject: [PATCH 068/132] emails: optimize /emails listing. Fix a suboptimal query that made the /emails page really slow. --- app/models/email_address.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/email_address.rb b/app/models/email_address.rb index ffe6175f1..7897d6335 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -12,7 +12,7 @@ class EmailAddress < ApplicationRecord def self.visible(user) if user.is_moderator? - where(user: User.where("level < ?", user.level)).or(where(user: user)) + where(user: User.where("level < ?", user.level).or(User.where(id: user.id))) else none end From db488c247ddea9fb4b547d696fdb0278c4908bd3 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 21:08:54 -0600 Subject: [PATCH 069/132] ip bans: fix deleted field in /ip_bans search form. Fix the value not being remembered in the search form because we accidentally used `input_html` instead of `selected`. --- app/views/ip_bans/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/ip_bans/index.html.erb b/app/views/ip_bans/index.html.erb index b3bdf29db..326f356cc 100644 --- a/app/views/ip_bans/index.html.erb +++ b/app/views/ip_bans/index.html.erb @@ -7,7 +7,7 @@ <%= f.input :reason, input_html: { value: params[:search][:reason] } %> <%= f.input :creator_name, label: "Creator", input_html: { value: params[:search][:creator_name], "data-autocomplete": "user" } %> <%= f.input :category, collection: IpBan.categories, include_blank: true, selected: params[:search][:category] %> - <%= f.input :is_deleted, label: "Deleted?", collection: ["Yes", "No"], include_blank: true, input_html: { value: params[:search][:is_deleted] } %> + <%= f.input :is_deleted, label: "Status", collection: [["Active", "false"], ["Deleted", "true"]], include_blank: true, selected: params[:search][:is_deleted] %> <%= f.input :order, collection: [%w[Newest created_at], %w[Oldest created_at_asc], %w[Last\ Seen last_hit_at]], include_blank: true, selected: params[:search][:order] %> <%= f.submit "Search" %> <% end %> From 7a2f72ce98d5e325481f98911bfe47df77711917 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 21:16:16 -0600 Subject: [PATCH 070/132] ip bans: fix /ip_bans listing not showing subnet. --- app/views/ip_bans/index.html.erb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/ip_bans/index.html.erb b/app/views/ip_bans/index.html.erb index 326f356cc..7313ec31c 100644 --- a/app/views/ip_bans/index.html.erb +++ b/app/views/ip_bans/index.html.erb @@ -14,7 +14,7 @@ <%= table_for @ip_bans, class: "striped autofit", width: "100%" do |t| %> <% t.column "IP Address" do |ip_ban| %> - <%= link_to_ip ip_ban.subnetted_ip %> + <%= link_to ip_ban.subnetted_ip, ip_address_path(ip_ban.ip_addr.to_s) %> <% end %> <% t.column :reason, td: { class: "col-expand" } %> <% t.column "Status" do |ip_ban| %> @@ -35,7 +35,6 @@
      <%= time_ago_in_words_tagged(ip_ban.created_at) %>
      <% end %> <% t.column column: "control" do |ip_ban| %> - <%= link_to "Details", ip_address_path(ip_ban.ip_addr.to_s) %> | <% if ip_ban.is_deleted? %> <%= link_to "Undelete", ip_ban_path(ip_ban), remote: true, method: :put, "data-params": "ip_ban[is_deleted]=false", "data-confirm": "Are you sure you want to undelete this IP ban?" %> <% else %> From 8221c8dcba50ec9fc380109c64e5239b41acdb60 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 21:34:01 -0600 Subject: [PATCH 071/132] users: inline search form on /users index page. * Add the user search form to the /users page. * Remove the /users/search page. --- app/controllers/users_controller.rb | 3 --- app/views/static/site_map.html.erb | 1 - app/views/users/_secondary_links.html.erb | 1 - app/views/users/index.html.erb | 9 +++++++++ app/views/users/search.html.erb | 20 -------------------- config/routes.rb | 1 - 6 files changed, 9 insertions(+), 26 deletions(-) delete mode 100644 app/views/users/search.html.erb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e18888579..eb0e2f7b4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -38,9 +38,6 @@ class UsersController < ApplicationController respond_with(@users) end - def search - end - def show @user = authorize User.find(params[:id]) respond_with(@user, methods: @user.full_attributes) do |format| diff --git a/app/views/static/site_map.html.erb b/app/views/static/site_map.html.erb index 893ca7b3b..d6a93fdfe 100644 --- a/app/views/static/site_map.html.erb +++ b/app/views/static/site_map.html.erb @@ -133,7 +133,6 @@ <% end %>
    • <%= link_to_wiki "Help", "help:users" %>
    • <%= link_to("Listing", users_path) %>
    • -
    • <%= link_to("Search", search_users_path) %>
    • <%= link_to("Bans", bans_path) %>
    • <%= link_to("Feedback", user_feedbacks_path) %>
    • <%= link_to("Terms of Service", terms_of_service_path) %>
    • diff --git a/app/views/users/_secondary_links.html.erb b/app/views/users/_secondary_links.html.erb index e1b885966..4487865cd 100644 --- a/app/views/users/_secondary_links.html.erb +++ b/app/views/users/_secondary_links.html.erb @@ -1,7 +1,6 @@ <% content_for(:secondary_links) do %> <%= quick_search_form_for(:name_matches, users_path, "users", autocomplete: "user", redirect: true) %> <%= subnav_link_to "Listing", users_path %> - <%= subnav_link_to "Search", search_users_path %> <% if CurrentUser.user.is_anonymous? %> <%= subnav_link_to "Sign up", new_user_path %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 2c3bae957..beb6c91e8 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -2,6 +2,15 @@

      Users

      + <%= search_form_for(users_path) do |f| %> + <%= f.input :name_matches, label: "Name", hint: "Use * for wildcard", input_html: { value: params[:search][:name_matches], data: { autocomplete: "user" } } %> + <%= f.input :level, collection: User.level_hash.to_a, include_blank: true, selected: params[:search][:level] %> + <%= f.input :can_upload_free, label: "Contributor?", as: :select, include_blank: true, selected: params[:search][:can_upload_free] %> + <%= f.input :can_approve_posts, label: "Approver?", as: :select, include_blank: true, selected: params[:search][:can_approve_posts] %> + <%= f.input :order, collection: [["Joined", "date"], ["Name", "name"], ["Posts", "post_upload_count"], ["Edits", "post_update_count"], ["Notes", "note_count"]], include_blank: true, selected: params[:search][:order] %> + <%= f.submit "Search" %> + <% end %> + <%= table_for @users, width: "100%" do |t| %> <% t.column column: "control" do |user| %> <% if policy(CurrentUser.user).promote? %> diff --git a/app/views/users/search.html.erb b/app/views/users/search.html.erb deleted file mode 100644 index c41962a0b..000000000 --- a/app/views/users/search.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -
      - -
      - -<%= render "secondary_links" %> diff --git a/config/routes.rb b/config/routes.rb index 13675fad2..d17321a92 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -243,7 +243,6 @@ Rails.application.routes.draw do end collection do - get :search get :custom_style end end From 025631ee64eda80aec69fbcc6a113ab04bd98729 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 21:38:48 -0600 Subject: [PATCH 072/132] users: show IPs to mods on /users page. --- app/views/users/index.html.erb | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index beb6c91e8..625be9e6a 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -11,40 +11,38 @@ <%= f.submit "Search" %> <% end %> - <%= table_for @users, width: "100%" do |t| %> - <% t.column column: "control" do |user| %> - <% if policy(CurrentUser.user).promote? %> - <%= link_to "Edit", edit_admin_user_path(user) %> - <% end %> - <% end %> - <% t.column "Name" do |user| %> + <%= table_for @users, width: "100%", class: "striped autofit" do |t| %> + <% t.column "Name", td: { class: "col-expand" } do |user| %> <%= link_to_user user %> - <% if user.inviter %> - ← <%= link_to_user user.inviter %> + <% end %> + <% if policy(IpAddress).show? %> + <% t.column "IP" do |user| %> + <%= link_to user.last_ip_addr, ip_address_path(user.last_ip_addr) %> <% end %> <% end %> <% t.column "Posts" do |user| %> <%= link_to user.post_upload_count, posts_path(:tags => "user:#{user.name}") %> <% end %> - <% t.column "Deleted" do |user| %> - <%= user.posts.deleted.count %> + <% t.column "Edits" do |user| %> + <%= link_to user.post_update_count, post_versions_path(:search => {:updater_id => user.id}) %> <% end %> <% t.column "Notes" do |user| %> <%= link_to user.note_update_count, note_versions_path(:search => {:updater_id => user.id}) %> <% end %> - <% t.column "Edits" do |user| %> - <%= link_to user.post_update_count, post_versions_path(:search => {:updater_id => user.id}) %> - <% end %> <% t.column "Level" do |user| %> <%= user.level_string %> <% end %> <% t.column "Joined" do |user| %> <%= compact_time user.created_at %> <% end %> + <% t.column column: "control" do |user| %> + <% if policy(CurrentUser.user).promote? %> + <%= link_to "Promote", edit_admin_user_path(user) %> + <% end %> + <% end %> <% end %> <%= numbered_paginator(@users) %> -
    From 3c4781f6d842315ba91d887dbf95ca7c100bcbb3 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 22:32:52 -0600 Subject: [PATCH 073/132] users: update last_logged_in_at hourly. Update last_logged_in_at on an hourly basis instead of a weekly basis. --- app/logical/session_loader.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/logical/session_loader.rb b/app/logical/session_loader.rb index d4ecf45d1..2407d5520 100644 --- a/app/logical/session_loader.rb +++ b/app/logical/session_loader.rb @@ -87,7 +87,7 @@ class SessionLoader def update_last_logged_in_at return if CurrentUser.is_anonymous? - return if CurrentUser.last_logged_in_at && CurrentUser.last_logged_in_at > 1.week.ago + return if CurrentUser.last_logged_in_at && CurrentUser.last_logged_in_at > 1.hour.ago CurrentUser.user.update_attribute(:last_logged_in_at, Time.now) end From fbb4cfb80762dcb8b199513ef27765d1d829fb0a Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 21 Dec 2020 22:40:48 -0600 Subject: [PATCH 074/132] users: let mods see users' last login time. --- app/policies/user_policy.rb | 4 ++++ app/views/users/_statistics.html.erb | 7 +++++++ app/views/users/index.html.erb | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 6ca305635..fa1dff0f4 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -23,6 +23,10 @@ class UserPolicy < ApplicationPolicy user.is_member? end + def can_see_last_logged_in_at? + user.is_moderator? + end + def can_see_favorites? user.is_admin? || record.id == user.id || !record.enable_private_favorites? end diff --git a/app/views/users/_statistics.html.erb b/app/views/users/_statistics.html.erb index 2a8ee6ec9..fe708c18c 100644 --- a/app/views/users/_statistics.html.erb +++ b/app/views/users/_statistics.html.erb @@ -11,6 +11,13 @@ <%= presenter.join_date %> + <% if policy(User).can_see_last_logged_in_at? %> + + Last Seen + <%= time_ago_in_words_tagged(user.last_logged_in_at) %> + + <% end %> + <% if policy(IpAddress).show? %> Last IP diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 625be9e6a..fde5ef637 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -32,6 +32,11 @@ <% t.column "Level" do |user| %> <%= user.level_string %> <% end %> + <% if policy(User).can_see_last_logged_in_at? %> + <% t.column "Last Seen" do |user| %> + <%= time_ago_in_words_tagged(user.last_logged_in_at) %> + <% end %> + <% end %> <% t.column "Joined" do |user| %> <%= compact_time user.created_at %> <% end %> From a084da2dbe460e0081f3f3163082e61110386dc2 Mon Sep 17 00:00:00 2001 From: evazion Date: Tue, 22 Dec 2020 02:15:21 -0600 Subject: [PATCH 075/132] artists: hide other names of banned artists on index page. Don't show other names of banned artists on the /artists page to anonymous users. Hides potentially sensitive information from Google and logged out users. --- app/views/artists/index.html.erb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/artists/index.html.erb b/app/views/artists/index.html.erb index ed8edf733..4b16fcaf7 100644 --- a/app/views/artists/index.html.erb +++ b/app/views/artists/index.html.erb @@ -13,8 +13,10 @@ <% end %> <% end %> <% t.column "Other Names", td: {class: "col-expand"} do |artist| %> - <% artist.other_names.each do |name| %> - <%= link_to name, artists_path(search: { any_name_matches: name }), class: "artist-other-name", rel: "nofollow" %> + <% unless artist.is_banned? && !policy(artist).can_view_banned? %> + <% artist.other_names.each do |name| %> + <%= link_to name, artists_path(search: { any_name_matches: name }), class: "artist-other-name", rel: "nofollow" %> + <% end %> <% end %> <% end %> <% t.column "Status" do |artist| %> From a947a10c53c79a6d4b2f97862560f965c0fb2581 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 17:51:53 -0600 Subject: [PATCH 076/132] config: add debug_mode option. Add a debug mode option. This is useful when debugging failed tests. Debug mode disables parallel testing so you can set breakpoints in tests with binding.pry (normally parallel testing makes it hard to set breakpoints). Debug mode also disables global exception handling for controllers. This lets exceptions bubble up to the console during controller tests (normally exceptions are swallowed by the controller, which prevents you from seeing backtraces in failed controller tests). --- app/controllers/application_controller.rb | 2 ++ config/danbooru_default_config.rb | 9 +++++++++ test/test_helper.rb | 10 ++++++---- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8f6faeb53..843b72670 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -88,6 +88,8 @@ class ApplicationController < ActionController::Base end def rescue_exception(exception) + return if Danbooru.config.debug_mode + case exception when ActionView::Template::Error rescue_exception(exception.cause) diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 0bb6d3459..beed3096d 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -52,6 +52,15 @@ module Danbooru "_danbooru2_session" end + # Debug mode does some things to make testing easier. It disables parallel + # testing and it replaces Danbooru's custom exception page with the default + # Rails exception page. This is only useful during development and testing. + # + # Usage: `DANBOORU_DEBUG_MODE=true bin/rails test + def debug_mode + false + end + def source_code_url "https://github.com/danbooru/danbooru" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0959a8ccd..1af778120 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -30,11 +30,13 @@ class ActiveSupport::TestCase mock_post_version_service! mock_pool_version_service! - parallelize - parallelize_setup do |worker| - Rails.application.load_seed + unless Danbooru.config.debug_mode + parallelize + parallelize_setup do |worker| + Rails.application.load_seed - SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" + SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}" + end end parallelize_teardown do |worker| From dbb66ace9022872791b72c2ca6fa2d4a1ece425a Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 13:19:03 -0600 Subject: [PATCH 077/132] routes: replace hardcoded routes in models with route helpers. Add a Routes module that gives models access to route helpers outside of views, and use it to replace various hardcoded routes. --- app/logical/d_text.rb | 6 +++--- app/logical/routes.rb | 11 +++++++++++ app/logical/sources/strategies/pixiv.rb | 6 +++--- app/logical/upload_service/replacer.rb | 2 +- app/logical/user_promotion.rb | 6 +++--- app/models/bulk_update_request.rb | 2 +- app/models/comment.rb | 2 +- app/models/forum_post.rb | 2 +- app/models/post.rb | 6 ++++-- app/models/user_feedback.rb | 6 +++--- app/models/wiki_page.rb | 2 +- test/unit/sources/pixiv_test.rb | 2 +- test/unit/user_feedback_test.rb | 2 +- 13 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 app/logical/routes.rb diff --git a/app/logical/d_text.rb b/app/logical/d_text.rb index d0982a6b8..f665c4923 100644 --- a/app/logical/d_text.rb +++ b/app/logical/d_text.rb @@ -109,11 +109,11 @@ class DText end if obj.is_approved? - "The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} has been approved by <@#{obj.approver.name}>.\n\n#{embedded_script}" + "The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} has been approved by <@#{obj.approver.name}>.\n\n#{embedded_script}" elsif obj.is_pending? - "The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} is pending approval.\n\n#{embedded_script}" + "The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} is pending approval.\n\n#{embedded_script}" elsif obj.is_rejected? - "The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} has been rejected.\n\n#{embedded_script}" + "The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} has been rejected.\n\n#{embedded_script}" end end end diff --git a/app/logical/routes.rb b/app/logical/routes.rb new file mode 100644 index 000000000..8af010188 --- /dev/null +++ b/app/logical/routes.rb @@ -0,0 +1,11 @@ +# Allow Rails URL helpers to be used outside of views. +# Example: Routes.posts_path(tags: "touhou") => /posts?tags=touhou + +class Routes + include Singleton + include Rails.application.routes.url_helpers + + class << self + delegate_missing_to :instance + end +end diff --git a/app/logical/sources/strategies/pixiv.rb b/app/logical/sources/strategies/pixiv.rb index 25700d0fd..00d5ff86b 100644 --- a/app/logical/sources/strategies/pixiv.rb +++ b/app/logical/sources/strategies/pixiv.rb @@ -65,15 +65,15 @@ module Sources text = text.gsub(%r{https?://www\.pixiv\.net/member_illust\.php\?mode=medium&illust_id=([0-9]+)}i) do |_match| pixiv_id = $1 - %(pixiv ##{pixiv_id} "»":[/posts?tags=pixiv:#{pixiv_id}]) + %(pixiv ##{pixiv_id} "»":[#{Routes.posts_path(tags: "pixiv:#{pixiv_id}")}]) end text = text.gsub(%r{https?://www\.pixiv\.net/member\.php\?id=([0-9]+)}i) do |_match| member_id = $1 profile_url = "https://www.pixiv.net/users/#{member_id}" - search_params = {"search[url_matches]" => profile_url}.to_param + artist_search_url = Routes.artists_path(search: { url_matches: profile_url }) - %("user/#{member_id}":[#{profile_url}] "»":[/artists?#{search_params}]) + %("user/#{member_id}":[#{profile_url}] "»":[#{artist_search_url}]) end text = text.gsub(/\r\n|\r|\n/, "
    ") diff --git a/app/logical/upload_service/replacer.rb b/app/logical/upload_service/replacer.rb index f64198c5a..2a8d8b62b 100644 --- a/app/logical/upload_service/replacer.rb +++ b/app/logical/upload_service/replacer.rb @@ -11,7 +11,7 @@ class UploadService end def comment_replacement_message(post, replacement) - %("#{replacement.creator.name}":[/users/#{replacement.creator.id}] replaced this post with a new image:\n\n#{replacement_message(post, replacement)}) + %("#{replacement.creator.name}":[#{Routes.user_path(replacement.creator)}] replaced this post with a new image:\n\n#{replacement_message(post, replacement)}) end def replacement_message(post, replacement) diff --git a/app/logical/user_promotion.rb b/app/logical/user_promotion.rb index e9653bb2d..6420db5e3 100644 --- a/app/logical/user_promotion.rb +++ b/app/logical/user_promotion.rb @@ -32,16 +32,16 @@ class UserPromotion def create_mod_actions if old_can_approve_posts != user.can_approve_posts? - ModAction.log("\"#{promoter.name}\":/users/#{promoter.id} changed approval privileges for \"#{user.name}\":/users/#{user.id} from #{old_can_approve_posts} to [b]#{user.can_approve_posts?}[/b]", :user_approval_privilege) + ModAction.log("\"#{promoter.name}\":#{Routes.user_path(promoter)} changed approval privileges for \"#{user.name}\":#{Routes.user_path(user)} from #{old_can_approve_posts} to [b]#{user.can_approve_posts?}[/b]", :user_approval_privilege, promoter) end if old_can_upload_free != user.can_upload_free? - ModAction.log("\"#{promoter.name}\":/users/#{promoter.id} changed unlimited upload privileges for \"#{user.name}\":/users/#{user.id} from #{old_can_upload_free} to [b]#{user.can_upload_free?}[/b]", :user_upload_privilege) + ModAction.log("\"#{promoter.name}\":#{Routes.user_path(promoter)} changed unlimited upload privileges for \"#{user.name}\":#{Routes.user_path(user)} from #{old_can_upload_free} to [b]#{user.can_upload_free?}[/b]", :user_upload_privilege, promoter) end if user.level_changed? category = is_upgrade ? :user_account_upgrade : :user_level_change - ModAction.log(%{"#{user.name}":/users/#{user.id} level changed #{user.level_string_was} -> #{user.level_string}}, category) + ModAction.log(%{"#{user.name}":#{Routes.user_path(user)} level changed #{user.level_string_was} -> #{user.level_string}}, category, promoter) end end diff --git a/app/models/bulk_update_request.rb b/app/models/bulk_update_request.rb index d7afa226d..b40bcabca 100644 --- a/app/models/bulk_update_request.rb +++ b/app/models/bulk_update_request.rb @@ -89,7 +89,7 @@ class BulkUpdateRequest < ApplicationRecord end def bulk_update_request_link - %{"bulk update request ##{id}":/bulk_update_requests?search%5Bid%5D=#{id}} + %{"bulk update request ##{id}":#{Routes.bulk_update_requests_path(search: { id: id })}} end end diff --git a/app/models/comment.rb b/app/models/comment.rb index 2207706ef..c56271f56 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -21,7 +21,7 @@ class Comment < ApplicationRecord mentionable( :message_field => :body, :title => ->(user_name) {"#{creator.name} mentioned you in a comment on post ##{post_id}"}, - :body => ->(user_name) {"@#{creator.name} mentioned you in a \"comment\":/posts/#{post_id}#comment-#{id} on post ##{post_id}:\n\n[quote]\n#{DText.extract_mention(body, "@" + user_name)}\n[/quote]\n"} + :body => ->(user_name) {"@#{creator.name} mentioned you in a \"comment\":#{Routes.post_path(post, anchor: "comment-#{id}")} on post ##{post_id}:\n\n[quote]\n#{DText.extract_mention(body, "@" + user_name)}\n[/quote]\n"} ) module SearchMethods diff --git a/app/models/forum_post.rb b/app/models/forum_post.rb index c8aad7962..8cab6029b 100644 --- a/app/models/forum_post.rb +++ b/app/models/forum_post.rb @@ -28,7 +28,7 @@ class ForumPost < ApplicationRecord mentionable( :message_field => :body, :title => ->(user_name) {%{#{creator.name} mentioned you in topic ##{topic_id} (#{topic.title})}}, - :body => ->(user_name) {%{@#{creator.name} mentioned you in topic ##{topic_id} ("#{topic.title}":[/forum_topics/#{topic_id}?page=#{forum_topic_page}]):\n\n[quote]\n#{DText.extract_mention(body, "@" + user_name)}\n[/quote]\n}} + :body => ->(user_name) {%{@#{creator.name} mentioned you in topic ##{topic_id} ("#{topic.title}":[#{Routes.forum_topic_path(topic, page: forum_topic_page)}]):\n\n[quote]\n#{DText.extract_mention(body, "@" + user_name)}\n[/quote]\n}} ) module SearchMethods diff --git a/app/models/post.rb b/app/models/post.rb index 58e7079d3..e89d79e34 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1390,7 +1390,8 @@ class Post < ApplicationRecord new_artist_tags.each do |tag| if tag.artist.blank? - warnings.add(:base, "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[/artists/new?artist%5Bname%5D=#{CGI.escape(tag.name)}]") + new_artist_path = Routes.new_artist_path(artist: { name: tag.name }) + warnings.add(:base, "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[#{new_artist_path}]") end end end @@ -1412,7 +1413,8 @@ class Post < ApplicationRecord return if tags.any?(&:artist?) return if Sources::Strategies.find(source).is_a?(Sources::Strategies::Null) - warnings.add(:base, "Artist tag is required. \"Create new artist tag\":[/artists/new?artist%5Bsource%5D=#{CGI.escape(source)}]. Ask on the forum if you need naming help") + new_artist_path = Routes.new_artist_path(artist: { source: source }) + warnings.add(:base, "Artist tag is required. \"Create new artist tag\":[#{new_artist_path}]. Ask on the forum if you need naming help") end def has_copyright_tag diff --git a/app/models/user_feedback.rb b/app/models/user_feedback.rb index cda52916c..75a0f7dc3 100644 --- a/app/models/user_feedback.rb +++ b/app/models/user_feedback.rb @@ -8,10 +8,10 @@ class UserFeedback < ApplicationRecord validates_inclusion_of :category, :in => %w(positive negative neutral) after_create :create_dmail, unless: :disable_dmail_notification after_update(:if => ->(rec) { CurrentUser.id != rec.creator_id}) do |rec| - ModAction.log(%{#{CurrentUser.name} updated user feedback for "#{rec.user.name}":/users/#{rec.user_id}}, :user_feedback_update) + ModAction.log(%{#{CurrentUser.name} updated user feedback for "#{rec.user.name}":#{Routes.user_path(rec.user)}}, :user_feedback_update) end after_destroy(:if => ->(rec) { CurrentUser.id != rec.creator_id}) do |rec| - ModAction.log(%{#{CurrentUser.name} deleted user feedback for "#{rec.user.name}":/users/#{rec.user_id}}, :user_feedback_delete) + ModAction.log(%{#{CurrentUser.name} deleted user feedback for "#{rec.user.name}":#{Routes.user_path(rec.user)}}, :user_feedback_delete) end deletable @@ -52,7 +52,7 @@ class UserFeedback < ApplicationRecord end def create_dmail - body = %{#{disclaimer}@#{creator.name} created a "#{category} record":/user_feedbacks?search[user_id]=#{user_id} for your account:\n\n#{self.body}} + body = %{#{disclaimer}@#{creator.name} created a "#{category} record":#{Routes.user_feedbacks_path(search: { user_id: user_id })} for your account:\n\n#{self.body}} Dmail.create_automated(:to_id => user_id, :title => "Your user record has been updated", :body => body) end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 236ba0410..a38f782d0 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -122,7 +122,7 @@ class WikiPage < ApplicationRecord broken_wikis = WikiPage.linked_to(title_was) if broken_wikis.count > 0 - broken_wiki_search = Rails.application.routes.url_helpers.wiki_pages_path(search: { linked_to: title_was }) + broken_wiki_search = Routes.wiki_pages_path(search: { linked_to: title_was }) warnings.add(:base, %!Warning: [[#{title_was}]] is still linked from "#{broken_wikis.count} #{"other wiki page".pluralize(broken_wikis.count)}":[#{broken_wiki_search}]. Update #{(broken_wikis.count > 1) ? "these wikis" : "this wiki"} to link to [[#{title}]] instead!) end end diff --git a/test/unit/sources/pixiv_test.rb b/test/unit/sources/pixiv_test.rb index a68d75ad0..a69106c3d 100644 --- a/test/unit/sources/pixiv_test.rb +++ b/test/unit/sources/pixiv_test.rb @@ -195,7 +195,7 @@ module Sources should "convert illust links and member links to dtext" do get_source("https://www.pixiv.net/member_illust.php?mode=medium&illust_id=63421642") - dtext_desc = %(foo 【pixiv #46337015 "»":[/posts?tags=pixiv:46337015]】bar 【pixiv #14901720 "»":[/posts?tags=pixiv:14901720]】\n\nbaz【"user/83739":[https://www.pixiv.net/users/83739] "»":[/artists?search%5Burl_matches%5D=https%3A%2F%2Fwww.pixiv.net%2Fusers%2F83739]】) + dtext_desc = %(foo 【pixiv #46337015 "»":[/posts?tags=pixiv%3A46337015]】bar 【pixiv #14901720 "»":[/posts?tags=pixiv%3A14901720]】\n\nbaz【"user/83739":[https://www.pixiv.net/users/83739] "»":[/artists?search%5Burl_matches%5D=https%3A%2F%2Fwww.pixiv.net%2Fusers%2F83739]】) assert_equal(dtext_desc, @site.dtext_artist_commentary_desc) end end diff --git a/test/unit/user_feedback_test.rb b/test/unit/user_feedback_test.rb index 516a79fd2..5f20faa66 100644 --- a/test/unit/user_feedback_test.rb +++ b/test/unit/user_feedback_test.rb @@ -7,7 +7,7 @@ class UserFeedbackTest < ActiveSupport::TestCase gold = FactoryBot.create(:gold_user) member = FactoryBot.create(:user) dmail = <<~EOS.chomp - @#{gold.name} created a "positive record":/user_feedbacks?search[user_id]=#{user.id} for your account: + @#{gold.name} created a "positive record":/user_feedbacks?search%5Buser_id%5D=#{user.id} for your account: good job! EOS From ca742db07a15f7e4b8f10e8f091acc2c4a3b745d Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 19:48:35 -0600 Subject: [PATCH 078/132] routes: remove legacy /user/index and /artist/index API endpoints. These endpoints get zero traffic. --- app/controllers/legacy_controller.rb | 8 -------- app/models/user.rb | 9 --------- app/views/legacy/artists.json.erb | 1 - app/views/legacy/artists.xml.erb | 6 ------ app/views/legacy/users.json.erb | 1 - app/views/legacy/users.xml.erb | 6 ------ config/routes.rb | 4 ---- 7 files changed, 35 deletions(-) delete mode 100644 app/views/legacy/artists.json.erb delete mode 100644 app/views/legacy/artists.xml.erb delete mode 100644 app/views/legacy/users.json.erb delete mode 100644 app/views/legacy/users.xml.erb diff --git a/app/controllers/legacy_controller.rb b/app/controllers/legacy_controller.rb index 5557a3146..784b19443 100644 --- a/app/controllers/legacy_controller.rb +++ b/app/controllers/legacy_controller.rb @@ -17,18 +17,10 @@ class LegacyController < ApplicationController end end - def users - @users = User.limit(100).search(params).paginate(params[:page]) - end - def tags @tags = Tag.limit(100).search(params).paginate(params[:page], :limit => params[:limit]) end - def artists - @artists = Artist.limit(100).search(search_params).paginate(params[:page]) - end - def unavailable render :plain => "this resource is no longer available", :status => 410 end diff --git a/app/models/user.rb b/app/models/user.rb index c56a19adb..071c42d04 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -483,15 +483,6 @@ class User < ApplicationRecord ] end - def to_legacy_json - return { - "name" => name, - "id" => id, - "level" => level, - "created_at" => created_at.strftime("%Y-%m-%d %H:%M") - }.to_json - end - def api_token api_key.try(:key) end diff --git a/app/views/legacy/artists.json.erb b/app/views/legacy/artists.json.erb deleted file mode 100644 index f7b4addaa..000000000 --- a/app/views/legacy/artists.json.erb +++ /dev/null @@ -1 +0,0 @@ -[<%= raw @artists.map(&:to_json).join(",") %>] diff --git a/app/views/legacy/artists.xml.erb b/app/views/legacy/artists.xml.erb deleted file mode 100644 index 60b65e14e..000000000 --- a/app/views/legacy/artists.xml.erb +++ /dev/null @@ -1,6 +0,0 @@ - - - <% @artists.each do |artist| %> - " is_active="<%= !artist.is_deleted? %>" name="<%= artist.name %>" updater_id="0" id="<%= artist.id %>" version="0"/> - <% end %> - diff --git a/app/views/legacy/users.json.erb b/app/views/legacy/users.json.erb deleted file mode 100644 index 296d0b8e0..000000000 --- a/app/views/legacy/users.json.erb +++ /dev/null @@ -1 +0,0 @@ -[<%= @users.map {|x| x.to_legacy_json}.join(", ").html_safe %>] diff --git a/app/views/legacy/users.xml.erb b/app/views/legacy/users.xml.erb deleted file mode 100644 index fe0dcb2c1..000000000 --- a/app/views/legacy/users.xml.erb +++ /dev/null @@ -1,6 +0,0 @@ - - - <% @users.each do |user| %> - - <% end %> - diff --git a/config/routes.rb b/config/routes.rb index d17321a92..8c5e55540 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -262,8 +262,6 @@ Rails.application.routes.draw do # legacy aliases get "/artist" => redirect {|params, req| "/artists?page=#{req.params[:page]}&search[name]=#{CGI.escape(req.params[:name].to_s)}"} - get "/artist/index.xml", :controller => "legacy", :action => "artists", :format => "xml" - get "/artist/index.json", :controller => "legacy", :action => "artists", :format => "json" get "/artist/index" => redirect {|params, req| "/artists?page=#{req.params[:page]}"} get "/artist/show/:id" => redirect("/artists/%{id}") get "/artist/show" => redirect {|params, req| "/artists?name=#{CGI.escape(req.params[:name].to_s)}"} @@ -335,8 +333,6 @@ Rails.application.routes.draw do get "/tag_implication" => redirect {|params, req| "/tag_implications?search[name_matches]=#{CGI.escape(req.params[:query].to_s)}"} - get "/user/index.xml", :controller => "legacy", :action => "users", :format => "xml" - get "/user/index.json", :controller => "legacy", :action => "users", :format => "json" get "/user" => redirect {|params, req| "/users?page=#{req.params[:page]}"} get "/user/index" => redirect {|params, req| "/users?page=#{req.params[:page]}"} get "/user/show/:id" => redirect("/users/%{id}") From a1cd9d2b5c3aba5bd5fd9cde0f7b91599b5a17d8 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 20:19:06 -0600 Subject: [PATCH 079/132] routes: remove unused Danbooru 1 redirects. Remove various redirects for old Danbooru 1 links. Most of these received little to no traffic and were only used in a small number of places in old comments or forum posts. --- config/routes.rb | 78 +++++++----------------------------------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 8c5e55540..27bb9c97d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -260,96 +260,40 @@ Rails.application.routes.draw do end end - # legacy aliases + # Legacy Danbooru 1 redirects. get "/artist" => redirect {|params, req| "/artists?page=#{req.params[:page]}&search[name]=#{CGI.escape(req.params[:name].to_s)}"} - get "/artist/index" => redirect {|params, req| "/artists?page=#{req.params[:page]}"} get "/artist/show/:id" => redirect("/artists/%{id}") get "/artist/show" => redirect {|params, req| "/artists?name=#{CGI.escape(req.params[:name].to_s)}"} - get "/artist/history/:id" => redirect("/artist_versions?search[artist_id]=%{id}") - get "/artist/recent_changes" => redirect("/artist_versions") - - get "/comment" => redirect {|params, req| "/comments?page=#{req.params[:page]}"} - get "/comment/index" => redirect {|params, req| "/comments?page=#{req.params[:page]}"} - get "/comment/show/:id" => redirect("/comments/%{id}") - get "/comment/new" => redirect("/comments") - get("/comment/search" => redirect do |params, req| - if req.params[:query] =~ /^user:(.+)/i - "/comments?group_by=comment&search[creator_name]=#{CGI.escape($1)}" - else - "/comments/search" - end - end) - - get "/favorite" => redirect {|params, req| "/favorites?page=#{req.params[:page]}"} - get "/favorite/index" => redirect {|params, req| "/favorites?page=#{req.params[:page]}"} - get "/favorite/list_users.json", :controller => "legacy", :action => "unavailable" get "/forum" => redirect {|params, req| "/forum_topics?page=#{req.params[:page]}"} - get "/forum/index" => redirect {|params, req| "/forum_topics?page=#{req.params[:page]}"} get "/forum/show/:id" => redirect {|params, req| "/forum_posts/#{req.params[:id]}?page=#{req.params[:page]}"} - get "/forum/search" => redirect("/forum_posts/search") - get "/help/:title" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape('help:' + req.params[:title])}"} - - get "/note" => redirect {|params, req| "/notes?page=#{req.params[:page]}"} - get "/note/index" => redirect {|params, req| "/notes?page=#{req.params[:page]}"} - get "/note/history" => redirect {|params, req| "/note_versions?search[updater_id]=#{req.params[:user_id]}"} - - get "/pool" => redirect {|params, req| "/pools?page=#{req.params[:page]}"} - get "/pool/index" => redirect {|params, req| "/pools?page=#{req.params[:page]}"} get "/pool/show/:id" => redirect("/pools/%{id}") - get "/pool/history/:id" => redirect("/pool_versions?search[pool_id]=%{id}") - get "/pool/recent_changes" => redirect("/pool_versions") - get "/post/index.xml", :controller => "legacy", :action => "posts", :format => "xml" - get "/post/index.json", :controller => "legacy", :action => "posts", :format => "json" - get "/post/piclens", :controller => "legacy", :action => "unavailable" get "/post/index" => redirect {|params, req| "/posts?tags=#{CGI.escape(req.params[:tags].to_s)}&page=#{req.params[:page]}"} - get "/post" => redirect {|params, req| "/posts?tags=#{CGI.escape(req.params[:tags].to_s)}&page=#{req.params[:page]}"} - get "/post/upload" => redirect("/uploads/new") - get "/post/moderate" => redirect("/moderator/post/queue") get "/post/atom" => redirect {|params, req| "/posts.atom?tags=#{CGI.escape(req.params[:tags].to_s)}"} - get "/post/atom.feed" => redirect {|params, req| "/posts.atom?tags=#{CGI.escape(req.params[:tags].to_s)}"} - get "/post/popular_by_day" => redirect("/explore/posts/popular") - get "/post/popular_by_week" => redirect("/explore/posts/popular") - get "/post/popular_by_month" => redirect("/explore/posts/popular") get "/post/show/:id/:tag_title" => redirect("/posts/%{id}") get "/post/show/:id" => redirect("/posts/%{id}") - get "/post/show" => redirect {|params, req| "/posts?md5=#{req.params[:md5]}"} - get "/post/view/:id/:tag_title" => redirect("/posts/%{id}") - get "/post/view/:id" => redirect("/posts/%{id}") - get "/post/flag/:id" => redirect("/posts/%{id}") - get("/post_tag_history" => redirect do |params, req| - page = req.params[:before_id].present? ? "b#{req.params[:before_id]}" : req.params[:page] - "/post_versions?page=#{page}&search[updater_id]=#{req.params[:user_id]}" - end) - get "/post_tag_history/index" => redirect {|params, req| "/post_versions?page=#{req.params[:page]}&search[post_id]=#{req.params[:post_id]}"} - - get "/tag/index.xml", :controller => "legacy", :action => "tags", :format => "xml" - get "/tag/index.json", :controller => "legacy", :action => "tags", :format => "json" get "/tag" => redirect {|params, req| "/tags?page=#{req.params[:page]}&search[name_matches]=#{CGI.escape(req.params[:name].to_s)}&search[order]=#{req.params[:order]}&search[category]=#{req.params[:type]}"} get "/tag/index" => redirect {|params, req| "/tags?page=#{req.params[:page]}&search[name_matches]=#{CGI.escape(req.params[:name].to_s)}&search[order]=#{req.params[:order]}"} - get "/tag_implication" => redirect {|params, req| "/tag_implications?search[name_matches]=#{CGI.escape(req.params[:query].to_s)}"} - - get "/user" => redirect {|params, req| "/users?page=#{req.params[:page]}"} - get "/user/index" => redirect {|params, req| "/users?page=#{req.params[:page]}"} get "/user/show/:id" => redirect("/users/%{id}") - get "/user/login" => redirect("/sessions/new") - get "/user_record" => redirect {|params, req| "/user_feedbacks?search[user_id]=#{req.params[:user_id]}"} + + get "/wiki/show" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape(req.params[:title].to_s)}"} + get "/help/:title" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape('help:' + req.params[:title])}"} + + # Legacy Danbooru 1 API endpoints + get "/tag/index.xml", :controller => "legacy", :action => "tags", :format => "xml" + get "/tag/index.json", :controller => "legacy", :action => "tags", :format => "json" + get "/post/index.xml", :controller => "legacy", :action => "posts", :format => "xml" + get "/post/index.json", :controller => "legacy", :action => "posts", :format => "json" + get "/login", to: "sessions#new", as: :login get "/logout", to: "sessions#sign_out", as: :logout get "/profile", to: "users#profile", as: :profile get "/settings", to: "users#settings", as: :settings - get "/wiki" => redirect {|params, req| "/wiki_pages?page=#{req.params[:page]}"} - get "/wiki/index" => redirect {|params, req| "/wiki_pages?page=#{req.params[:page]}"} - get "/wiki/rename" => redirect("/wiki_pages") - get "/wiki/show" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape(req.params[:title].to_s)}"} - get "/wiki/recent_changes" => redirect {|params, req| "/wiki_page_versions?search[updater_id]=#{req.params[:user_id]}"} - get "/wiki/history/:title" => redirect("/wiki_page_versions?title=%{title}") - get "/sitemap" => "static#sitemap_index" get "/opensearch" => "static#opensearch", :as => "opensearch" get "/privacy" => "static#privacy_policy", :as => "privacy_policy" From 039ccfa3af8c9f77402bf2847afbeb73b23111d8 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 22:31:46 -0600 Subject: [PATCH 080/132] routes: optimize route order. Put the most used routes at the top of the file to optimize route performance. --- config/routes.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 27bb9c97d..94cd168a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,11 @@ Rails.application.routes.draw do + resources :posts, only: [:index, :show, :update, :destroy] + resources :autocomplete, only: [:index] + + # XXX This comes *after* defining posts above because otherwise the paginator + # generates `/?page=2` instead of `/posts?page=2` on the posts#index page. + root "posts#index" + namespace :admin do resources :users, :only => [:edit, :update] resource :dashboard, :only => [:show] @@ -67,7 +74,6 @@ Rails.application.routes.draw do get :search end end - resources :autocomplete, only: [:index] resources :bans resources :bulk_update_requests do member do @@ -171,7 +177,9 @@ Rails.application.routes.draw do end resources :post_replacements, :only => [:index, :new, :create, :update] resources :post_votes, only: [:index] - resources :posts, only: [:index, :show, :update, :destroy] do + + # XXX Use `only: []` to avoid redefining post routes defined at top of file. + resources :posts, only: [] do resources :events, :only => [:index], :controller => "post_events" resources :replacements, :only => [:index, :new, :create], :controller => "post_replacements" resource :artist_commentary, :only => [:index, :show] do @@ -313,7 +321,5 @@ Rails.application.routes.draw do get "/mock/iqdbs/similar" => "mock_services#iqdbs_similar", as: "mock_iqdbs_similar" post "/mock/iqdbs/similar" => "mock_services#iqdbs_similar" - root :to => "posts#index" - get "*other", :to => "static#not_found" end From c17678d509f35b85f1955ae559dfbc1abdb4bd4f Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 24 Dec 2020 00:07:19 -0600 Subject: [PATCH 081/132] routes: add a new 404 page. * Fix a bug where non-GET 404 requests weren't handled. * Fix a bug where non-HTML 404 requests weren't handled. * Show a random image from a specified pool on the 404 page. --- app/controllers/application_controller.rb | 6 ++-- app/controllers/static_controller.rb | 10 +++++-- app/views/static/not_found.html.erb | 26 +++++++++++++++++ config/danbooru_default_config.rb | 5 ++++ config/routes.rb | 2 +- public/404.html | 15 ---------- public/500.html | 15 ---------- test/functional/static_controller_test.rb | 34 ++++++++++++++++++++++- 8 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 app/views/static/not_found.html.erb delete mode 100644 public/404.html delete mode 100644 public/500.html diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 843b72670..ad4f918b0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -124,17 +124,17 @@ class ApplicationController < ActionController::Base end end - def render_error_page(status, exception, message: exception.message, template: "static/error", format: request.format.symbol) + def render_error_page(status, exception = nil, message: exception.message, template: "static/error", format: request.format.symbol) @exception = exception @expected = status < 500 @message = message.encode("utf-8", invalid: :replace, undef: :replace) - @backtrace = Rails.backtrace_cleaner.clean(@exception.backtrace) + @backtrace = Rails.backtrace_cleaner.clean(@exception.backtrace) if @exception format = :html unless format.in?(%i[html json xml js atom]) # if InvalidAuthenticityToken was raised, CurrentUser isn't set so we have to use the blank layout. layout = CurrentUser.user.present? ? "default" : "blank" - DanbooruLogger.log(@exception, expected: @expected) + DanbooruLogger.log(@exception, expected: @expected) if @exception render template, layout: layout, status: status, formats: format rescue ActionView::MissingTemplate render "static/error", layout: layout, status: status, formats: format diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 709be20f3..331f20a42 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -1,4 +1,6 @@ class StaticController < ApplicationController + respond_to :html, :json, :xml + def privacy_policy end @@ -6,7 +8,11 @@ class StaticController < ApplicationController end def not_found - render plain: "not found", status: :not_found + @pool = Pool.find(Danbooru.config.page_not_found_pool_id) if Danbooru.config.page_not_found_pool_id.present? + @post = @pool.posts.sample if @pool.present? + @artist = @post.tags.select(&:artist?).first if @post.present? + + render_error_page(404, nil, template: "static/not_found", message: "Page not found") end def error @@ -38,7 +44,7 @@ class StaticController < ApplicationController @search = { is_deleted: "false" } when "posts" @relation = Post.order(id: :asc) - @serach = {} + @search = {} when "tags" @relation = Tag.nonempty @search = {} diff --git a/app/views/static/not_found.html.erb b/app/views/static/not_found.html.erb new file mode 100644 index 000000000..8393490a5 --- /dev/null +++ b/app/views/static/not_found.html.erb @@ -0,0 +1,26 @@ +<% page_title "Page not found" %> + +
    +
    +
    +

    Page not found

    + + <% if @post.present? && @artist.present? %> +

    + <%= link_to @post do %> + <%= tag.img src: @post.large_file_url %> + <% end %> + +

    + <%= link_to "post ##{@post.id}", @post %> + by <%= link_to @artist.name, posts_path(tags: @artist.name), class: tag_class(@artist) %> +

    +

    + <% else %> +

    Nobody here but us chickens!

    + <% end %> + +

    <%= link_to "Return to previous page", :back %>

    +
    +
    +
    diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index beed3096d..41fd594d9 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -281,6 +281,11 @@ module Danbooru restricted_tags + %w[censored condom nipples nude penis pussy sexually_suggestive] end + # If present, the 404 page will show a random post from this pool. + def page_not_found_pool_id + nil + end + # Tags that are only visible to Gold+ users. def restricted_tags [] diff --git a/config/routes.rb b/config/routes.rb index 94cd168a7..9ab7cacfa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -321,5 +321,5 @@ Rails.application.routes.draw do get "/mock/iqdbs/similar" => "mock_services#iqdbs_similar", as: "mock_iqdbs_similar" post "/mock/iqdbs/similar" => "mock_services#iqdbs_similar" - get "*other", :to => "static#not_found" + match "*other", to: "static#not_found", via: :all end diff --git a/public/404.html b/public/404.html deleted file mode 100644 index 108e3337c..000000000 --- a/public/404.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - Page not found - - - - - -
    -

    That page does not exist

    -

    Return to index

    -
    - - diff --git a/public/500.html b/public/500.html deleted file mode 100644 index b6f61c973..000000000 --- a/public/500.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - Failbooru - - - - - -
    -

    Something broke

    -

    Return to index

    -
    - - diff --git a/test/functional/static_controller_test.rb b/test/functional/static_controller_test.rb index faca87b71..dff4ff680 100644 --- a/test/functional/static_controller_test.rb +++ b/test/functional/static_controller_test.rb @@ -47,10 +47,42 @@ class StaticControllerTest < ActionDispatch::IntegrationTest end context "not_found action" do - should "work" do + should "return the 404 page for GET requests" do get "/qwoiqogieqg" assert_response 404 end + + should "return the 404 page for POST requests" do + post "/qwoiqogieqg" + assert_response 404 + end + + should "return a JSON response for a 404'd JSON request" do + get "/qwoiqogieqg", as: :json + + assert_response 404 + assert_equal("Page not found", response.parsed_body["message"]) + end + + should "return an XML response for a 404'd XML request" do + get "/qwoiqogieqg", as: :xml + + assert_response 404 + assert_equal("Page not found", response.parsed_body.at("result").text) + end + + should "render the 404 page when page_not_found_pool_id is configured" do + as(create(:user)) do + @post = create(:post, tag_string: "artist:bkub") + @pool = create(:pool, post_ids: [@post.id]) + Danbooru.config.stubs(:page_not_found_pool_id).returns(@pool.id) + end + + get "/qwoiqogieqg" + + assert_response 404 + assert_select "#c-static #a-not-found img", count: 1 + end end context "bookmarklet action" do From 7762489d7d9b4b7ed3408a989099dfb1d5ed84b9 Mon Sep 17 00:00:00 2001 From: evazion Date: Wed, 23 Dec 2020 05:15:08 -0600 Subject: [PATCH 082/132] user upgrades: upgrade to new Stripe checkout system. This upgrades from the legacy version of Stripe's checkout system to the new version: > The legacy version of Checkout presented customers with a modal dialog > that collected card information, and returned a token or a source to > your website. In contrast, the new version of Checkout is a smart > payment page hosted by Stripe that creates payments or subscriptions. It > supports Apple Pay, Dynamic 3D Secure, and many other features. Basic overview of the new system: * We send the user to a checkout page on Stripe. * Stripe collects payment and sends us a webhook notification when the order is complete. * We receive the webhook notification and upgrade the user. Docs: * https://stripe.com/docs/payments/checkout * https://stripe.com/docs/payments/checkout/migration#client-products * https://stripe.com/docs/payments/handling-payment-events * https://stripe.com/docs/payments/checkout/fulfill-orders --- app/controllers/user_upgrades_controller.rb | 43 +---- app/controllers/webhooks_controller.rb | 13 ++ app/helpers/user_upgrades_helper.rb | 20 --- app/logical/user_upgrade.rb | 138 ++++++++++++++- .../user_upgrades/_stripe_payment.html.erb | 10 -- .../_stripe_payment_safebooru.html.erb | 3 - app/views/user_upgrades/create.js.erb | 2 + app/views/user_upgrades/new.html.erb | 22 ++- config/danbooru_default_config.rb | 3 + config/initializers/stripe.rb | 1 + config/routes.rb | 3 + .../checkout.session.completed.json | 55 ++++++ .../payment_intent.created.json | 67 +++++++ .../user_upgrades_controller_test.rb | 109 ++---------- test/functional/webhooks_controller_test.rb | 167 ++++++++++++++++++ test/test_helper.rb | 1 + test/test_helpers/stripe_test_helper.rb | 13 ++ test/unit/user_upgrade_test.rb | 41 +++++ 18 files changed, 536 insertions(+), 175 deletions(-) create mode 100644 app/controllers/webhooks_controller.rb delete mode 100644 app/views/user_upgrades/_stripe_payment.html.erb delete mode 100644 app/views/user_upgrades/_stripe_payment_safebooru.html.erb create mode 100644 app/views/user_upgrades/create.js.erb create mode 100644 test/fixtures/stripe-webhooks/checkout.session.completed.json create mode 100644 test/fixtures/stripe-webhooks/payment_intent.created.json create mode 100644 test/functional/webhooks_controller_test.rb create mode 100644 test/test_helpers/stripe_test_helper.rb create mode 100644 test/unit/user_upgrade_test.rb diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index be7ee116a..e2b8729d6 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -1,11 +1,12 @@ class UserUpgradesController < ApplicationController helper_method :user - skip_before_action :verify_authenticity_token, only: [:create] + respond_to :js, :html def create - if params[:stripeToken] - create_stripe - end + @user_upgrade = UserUpgrade.new(recipient: user, purchaser: CurrentUser.user, level: params[:level].to_i) + @checkout = @user_upgrade.create_checkout + + respond_with(@user_upgrade) end def new @@ -22,38 +23,4 @@ class UserUpgradesController < ApplicationController CurrentUser.user end end - - private - - def create_stripe - @user = user - - if params[:desc] == "Upgrade to Gold" - level = User::Levels::GOLD - cost = UserUpgrade.gold_price - elsif params[:desc] == "Upgrade to Platinum" - level = User::Levels::PLATINUM - cost = UserUpgrade.platinum_price - elsif params[:desc] == "Upgrade Gold to Platinum" && @user.level == User::Levels::GOLD - level = User::Levels::PLATINUM - cost = UserUpgrade.upgrade_price - else - raise "Invalid desc" - end - - begin - charge = Stripe::Charge.create(amount: cost, currency: "usd", source: params[:stripeToken], description: params[:desc]) - @user.promote_to!(level, User.system, is_upgrade: true) - flash[:success] = true - rescue Stripe::StripeError => e - DanbooruLogger.log(e) - flash[:error] = e.message - end - - if @user == CurrentUser.user - redirect_to user_upgrade_path - else - redirect_to user_upgrade_path(user_id: params[:user_id]) - end - end end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 000000000..0ff5cdeae --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,13 @@ +class WebhooksController < ApplicationController + skip_forgery_protection only: :receive + rescue_with Stripe::SignatureVerificationError, status: 400 + + def receive + if params[:source] == "stripe" + UserUpgrade.receive_webhook(request) + head 200 + else + head 400 + end + end +end diff --git a/app/helpers/user_upgrades_helper.rb b/app/helpers/user_upgrades_helper.rb index 0b7c301aa..8d37de45d 100644 --- a/app/helpers/user_upgrades_helper.rb +++ b/app/helpers/user_upgrades_helper.rb @@ -1,24 +1,4 @@ module UserUpgradesHelper - def stripe_button(desc, cost, user) - html = %{ -
    - - #{hidden_field_tag(:desc, desc)} - #{hidden_field_tag(:user_id, user.id)} - -
    - } - - raw(html) - end - def cents_to_usd(cents) number_to_currency(cents / 100, precision: 0) end diff --git a/app/logical/user_upgrade.rb b/app/logical/user_upgrade.rb index b29909faf..f9af14708 100644 --- a/app/logical/user_upgrade.rb +++ b/app/logical/user_upgrade.rb @@ -1,13 +1,145 @@ class UserUpgrade + attr_reader :recipient, :purchaser, :level + + def self.stripe_publishable_key + Danbooru.config.stripe_publishable_key + end + + def self.stripe_webhook_secret + Danbooru.config.stripe_webhook_secret + end + def self.gold_price 2000 end def self.platinum_price - 4000 + 2 * gold_price end - def self.upgrade_price - 2000 + def self.gold_to_platinum_price + platinum_price - gold_price + end + + def initialize(recipient:, purchaser:, level:) + @recipient, @purchaser, @level = recipient, purchaser, level.to_i + end + + def upgrade_type + if level == User::Levels::GOLD && recipient.level == User::Levels::MEMBER + :gold_upgrade + elsif level == User::Levels::PLATINUM && recipient.level == User::Levels::MEMBER + :platinum_upgrade + elsif level == User::Levels::PLATINUM && recipient.level == User::Levels::GOLD + :gold_to_platinum_upgrade + else + raise ArgumentError, "Invalid upgrade" + end + end + + def upgrade_price + case upgrade_type + when :gold_upgrade + UserUpgrade.gold_price + when :platinum_upgrade + UserUpgrade.platinum_price + when :gold_to_platinum_upgrade + UserUpgrade.gold_to_platinum_price + end + end + + def upgrade_description + case upgrade_type + when :gold_upgrade + "Upgrade to Gold" + when :platinum_upgrade + "Upgrade to Platinum" + when :gold_to_platinum_upgrade + "Upgrade Gold to Platinum" + end + end + + def is_gift? + recipient != purchaser + end + + def process_upgrade! + recipient.with_lock do + upgrade_recipient! + end + end + + def upgrade_recipient! + recipient.promote_to!(level, User.system, is_upgrade: true) + end + + concerning :StripeMethods do + def create_checkout + Stripe::Checkout::Session.create( + mode: "payment", + success_url: Routes.user_upgrade_url(user_id: recipient.id), + cancel_url: Routes.new_user_upgrade_url(user_id: recipient.id), + client_reference_id: "user_#{purchaser.id}", + customer_email: recipient.email_address&.address, + payment_method_types: ["card"], + line_items: [{ + price_data: { + unit_amount: upgrade_price, + currency: "usd", + product_data: { + name: upgrade_description, + }, + }, + quantity: 1, + }], + metadata: { + purchaser_id: purchaser.id, + recipient_id: recipient.id, + purchaser_name: purchaser.name, + recipient_name: recipient.name, + upgrade_type: upgrade_type, + is_gift: is_gift?, + level: level, + }, + ) + end + + class_methods do + def register_webhook + webhook = Stripe::WebhookEndpoint.create({ + url: Routes.webhook_user_upgrade_url(source: "stripe"), + enabled_events: [ + "payment_intent.created", + "payment_intent.payment_failed", + "checkout.session.completed", + ], + }) + + webhook.secret + end + + def receive_webhook(request) + event = build_event(request) + + if event.type == "checkout.session.completed" + checkout_session_completed(event) + end + end + + def build_event(request) + payload = request.body.read + signature = request.headers["Stripe-Signature"] + Stripe::Webhook.construct_event(payload, signature, stripe_webhook_secret) + end + + def checkout_session_completed(event) + recipient = User.find(event.data.object.metadata.recipient_id) + purchaser = User.find(event.data.object.metadata.purchaser_id) + level = event.data.object.metadata.level + + user_upgrade = UserUpgrade.new(recipient: recipient, purchaser: purchaser, level: level) + user_upgrade.process_upgrade! + end + end end end diff --git a/app/views/user_upgrades/_stripe_payment.html.erb b/app/views/user_upgrades/_stripe_payment.html.erb deleted file mode 100644 index b9e43052d..000000000 --- a/app/views/user_upgrades/_stripe_payment.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -
    -

    You can pay with a credit or debit card. Safebooru uses Stripe as a payment intermediary so none of your personal information will be stored on the site.

    - - <% if user.level < User::Levels::GOLD %> - <%= stripe_button("Upgrade to Gold", UserUpgrade.gold_price, user) %> - <%= stripe_button("Upgrade to Platinum", UserUpgrade.platinum_price, user) %> - <% elsif user.level < User::Levels::PLATINUM %> - <%= stripe_button("Upgrade Gold to Platinum", UserUpgrade.upgrade_price, user) %> - <% end %> -
    diff --git a/app/views/user_upgrades/_stripe_payment_safebooru.html.erb b/app/views/user_upgrades/_stripe_payment_safebooru.html.erb deleted file mode 100644 index 730eab9f6..000000000 --- a/app/views/user_upgrades/_stripe_payment_safebooru.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
    -

    You can pay with a credit or debit card on <%= link_to "Safebooru", new_user_upgrade_url(host: "safebooru.donmai.us", protocol: "https") %>. Your account will then also be upgraded on Danbooru. You can login to Safebooru with the same username and password you use on Danbooru.

    -
    diff --git a/app/views/user_upgrades/create.js.erb b/app/views/user_upgrades/create.js.erb new file mode 100644 index 000000000..e08bc3438 --- /dev/null +++ b/app/views/user_upgrades/create.js.erb @@ -0,0 +1,2 @@ +var stripe = Stripe("<%= j UserUpgrade.stripe_publishable_key %>"); +stripe.redirectToCheckout({ sessionId: "<%= j @checkout.id %>" }); diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 4cd610bb3..2ed284092 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -1,5 +1,6 @@ <% page_title "Account Upgrade" %> -<% meta_description "Upgrade to a Gold or Platinum account on #{Danbooru.config.app_name}." %> +<% meta_description "Upgrade to a Gold or Platinum account." %> + <%= render "users/secondary_links" %> @@ -96,9 +97,24 @@ <% if CurrentUser.is_anonymous? %>

    <%= link_to "Sign up", new_user_path %> or <%= link_to "login", login_path(url: new_user_upgrade_path) %> first to upgrade your account.

    <% elsif CurrentUser.safe_mode? %> - <%= render "stripe_payment" %> +
    +

    You can pay with a credit or debit card. Safebooru uses Stripe + as a payment intermediary so none of your personal information will be stored on the site.

    + + <% if user.level < User::Levels::GOLD %> +

    <%= button_to "Upgrade to Gold", user_upgrade_path(user_id: user.id, level: User::Levels::GOLD), remote: true, disable_with: "Redirecting..." %>

    +

    <%= button_to "Upgrade to Platinum", user_upgrade_path(user_id: user.id, level: User::Levels::PLATINUM), remote: true, disable_with: "Redirecting..." %>

    + <% elsif user.level < User::Levels::PLATINUM %> +

    <%= button_to "Upgrade Gold to Platinum", user_upgrade_path(user_id: user.id, level: User::Levels::PLATINUM), remote: true, disable_with: "Redirecting..." %>

    + <% end %> +
    <% else %> - <%= render "stripe_payment_safebooru" %> +
    +

    You can pay with a credit or debit card on + <%= link_to "Safebooru", new_user_upgrade_url(user_id: user.id, host: "safebooru.donmai.us", protocol: "https") %>. + Your account will then also be upgraded on Danbooru. You can login to + Safebooru with the same username and password you use on Danbooru.

    +
    <% end %> <% end %> diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 41fd594d9..75622de73 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -359,6 +359,9 @@ module Danbooru def stripe_publishable_key end + def stripe_webhook_secret + end + def twitter_api_key end diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb index 43495044b..7a60018c9 100644 --- a/config/initializers/stripe.rb +++ b/config/initializers/stripe.rb @@ -1 +1,2 @@ Stripe.api_key = Danbooru.config.stripe_secret_key +Stripe.api_version = "2020-08-27" diff --git a/config/routes.rb b/config/routes.rb index 9ab7cacfa..b027bb3a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -257,6 +257,9 @@ Rails.application.routes.draw do resource :user_upgrade, :only => [:new, :create, :show] resources :user_feedbacks, except: [:destroy] resources :user_name_change_requests, only: [:new, :create, :show, :index] + resources :webhooks do + post :receive, on: :collection + end resources :wiki_pages, id: /.+?(?=\.json|\.xml|\.html)|.+/ do put :revert, on: :member get :search, on: :collection diff --git a/test/fixtures/stripe-webhooks/checkout.session.completed.json b/test/fixtures/stripe-webhooks/checkout.session.completed.json new file mode 100644 index 000000000..975bb555d --- /dev/null +++ b/test/fixtures/stripe-webhooks/checkout.session.completed.json @@ -0,0 +1,55 @@ +{ + "id": "evt_000", + "object": "event", + "api_version": "2020-08-27", + "created": 1608705740, + "data": { + "object": { + "id": "cs_test_000", + "object": "checkout.session", + "allow_promotion_codes": null, + "amount_subtotal": 2000, + "amount_total": 2000, + "billing_address_collection": null, + "cancel_url": "http://localhost/user_upgrade/new", + "client_reference_id": "user_12345", + "currency": "usd", + "customer": "cus_000", + "customer_email": null, + "livemode": false, + "locale": null, + "metadata": { + "purchaser_id": "12345", + "recipient_id": "12345", + "purchaser_name": "user_12345", + "recipient_name": "user_12345", + "upgrade_type": "gold_upgrade", + "is_gift": "false", + "level": "30" + }, + "mode": "payment", + "payment_intent": "pi_000", + "payment_method_types": [ + "card" + ], + "payment_status": "paid", + "setup_intent": null, + "shipping": null, + "shipping_address_collection": null, + "submit_type": null, + "subscription": null, + "success_url": "http://localhost/user_upgrade?user_id=12345", + "total_details": { + "amount_discount": 0, + "amount_tax": 0 + } + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" +} diff --git a/test/fixtures/stripe-webhooks/payment_intent.created.json b/test/fixtures/stripe-webhooks/payment_intent.created.json new file mode 100644 index 000000000..915aa9f90 --- /dev/null +++ b/test/fixtures/stripe-webhooks/payment_intent.created.json @@ -0,0 +1,67 @@ +{ + "id": "evt_000", + "object": "event", + "api_version": "2020-08-27", + "created": 1608705945, + "data": { + "object": { + "id": "pi_000", + "object": "payment_intent", + "amount": 2000, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_000" + }, + "client_secret": "pi_000", + "confirmation_method": "automatic", + "created": 1608705945, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_000", + "idempotency_key": null + }, + "type": "payment_intent.created" +} diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index db88aa327..58e92a8aa 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -1,14 +1,6 @@ require 'test_helper' class UserUpgradesControllerTest < ActionDispatch::IntegrationTest - setup do - StripeMock.start - end - - teardown do - StripeMock.stop - end - context "The user upgrades controller" do context "new action" do should "render" do @@ -25,103 +17,24 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end context "create action" do - setup do - @user = create(:user) - @token = StripeMock.generate_card_token - end + mock_stripe! - context "a self upgrade" do - should "upgrade a Member to Gold" do - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } - - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_gold?) - end - - should "upgrade a Member to Platinum" do - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Platinum" } - - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_platinum?) - end - - should "upgrade a Gold user to Platinum" do - @user.update!(level: User::Levels::GOLD) - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade Gold to Platinum" } - - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_platinum?) - end - - should "log an account upgrade modaction" do - assert_difference("ModAction.user_account_upgrade.count") do - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } - end - end - - should "send the user a dmail" do - assert_difference("@user.dmails.received.count") do - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } - end - end - end - - context "a gifted upgrade" do - should "upgrade the user to Gold" do - @other_user = create(:user) - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold", user_id: @other_user.id } - - assert_redirected_to user_upgrade_path(user_id: @other_user.id) - assert_equal(true, @other_user.reload.is_gold?) - assert_equal(false, @user.reload.is_gold?) - end - end - - context "an upgrade for a user above Platinum level" do - should "not demote the user" do - @builder = create(:builder_user) - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold", user_id: @builder.id } - - assert_response 403 - assert_equal(true, @builder.reload.is_builder?) - end - end - - context "an upgrade with a missing Stripe token" do - should "not upgrade the user" do - post_auth user_upgrade_path, @user, params: { desc: "Upgrade to Gold" } + context "for a self upgrade to Gold" do + should "redirect the user to the Stripe checkout page" do + user = create(:member_user) + post_auth user_upgrade_path(user_id: user.id), user, params: { level: User::Levels::GOLD }, xhr: true assert_response :success - assert_equal(true, @user.reload.is_member?) end end - context "an upgrade with an invalid Stripe token" do - should "not upgrade the user" do - post_auth user_upgrade_path, @user, params: { stripeToken: "garbage", desc: "Upgrade to Gold" } + context "for a gifted upgrade to Gold" do + should "redirect the user to the Stripe checkout page" do + recipient = create(:member_user) + purchaser = create(:member_user) + post_auth user_upgrade_path(user_id: recipient.id), purchaser, params: { level: User::Levels::GOLD }, xhr: true - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_member?) - end - end - - context "an upgrade with an credit card that is declined" do - should "not upgrade the user" do - StripeMock.prepare_card_error(:card_declined) - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } - - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_member?) - end - end - - context "an upgrade with an credit card that is expired" do - should "not upgrade the user" do - StripeMock.prepare_card_error(:expired_card) - post_auth user_upgrade_path, @user, params: { stripeToken: @token, desc: "Upgrade to Gold" } - - assert_redirected_to user_upgrade_path - assert_equal(true, @user.reload.is_member?) + assert_response :success end end end diff --git a/test/functional/webhooks_controller_test.rb b/test/functional/webhooks_controller_test.rb new file mode 100644 index 000000000..afb596be4 --- /dev/null +++ b/test/functional/webhooks_controller_test.rb @@ -0,0 +1,167 @@ +require 'test_helper' + +class WebhooksControllerTest < ActionDispatch::IntegrationTest + mock_stripe! + + def post_webhook(*args, **metadata) + event = StripeMock.mock_webhook_event(*args, metadata: metadata) + signature = generate_stripe_signature(event) + headers = { "Stripe-Signature": signature } + + post receive_webhooks_path(source: "stripe"), headers: headers, params: event, as: :json + end + + # https://github.com/stripe-ruby-mock/stripe-ruby-mock/issues/467#issuecomment-634674913 + # https://stripe.com/docs/webhooks/signatures + def generate_stripe_signature(event) + time = Time.now + secret = UserUpgrade.stripe_webhook_secret + signature = Stripe::Webhook::Signature.compute_signature(time, event.to_json, secret) + Stripe::Webhook::Signature.generate_header(time, signature, scheme: Stripe::Webhook::Signature::EXPECTED_SCHEME) + end + + context "The webhooks controller" do + context "receive action" do + context "for a request from an unrecognized source" do + should "fail" do + post receive_webhooks_path(source: "blah") + assert_response 400 + end + end + + context "for a Stripe webhook" do + context "with a missing signature" do + should "fail" do + event = StripeMock.mock_webhook_event("payment_intent.created") + post receive_webhooks_path(source: "stripe"), params: event, as: :json + + assert_response 400 + end + end + + context "with an invalid signature" do + should "fail" do + event = StripeMock.mock_webhook_event("payment_intent.created") + headers = { "Stripe-Signature": "blah" } + post receive_webhooks_path(source: "stripe"), headers: headers, params: event, as: :json + + assert_response 400 + end + end + + context "for a payment_intent.created event" do + should "work" do + post_webhook("payment_intent.created") + + assert_response 200 + end + end + + context "for a checkout.session.completed event" do + context "for a self upgrade" do + context "of a Member to Gold" do + should "upgrade the user" do + @user = create(:member_user) + + post_webhook("checkout.session.completed", { + recipient_id: @user.id, + purchaser_id: @user.id, + upgrade_type: "gold_upgrade", + level: User::Levels::GOLD, + }) + + assert_response 200 + assert_equal(User::Levels::GOLD, @user.reload.level) + end + end + + context "of a Member to Platinum" do + should "upgrade the user" do + @user = create(:member_user) + + post_webhook("checkout.session.completed", { + recipient_id: @user.id, + purchaser_id: @user.id, + upgrade_type: "platinum_upgrade", + level: User::Levels::PLATINUM, + }) + + assert_response 200 + assert_equal(User::Levels::PLATINUM, @user.reload.level) + end + end + + context "of a Gold user to Platinum" do + should "upgrade the user" do + @user = create(:gold_user) + + post_webhook("checkout.session.completed", { + recipient_id: @user.id, + purchaser_id: @user.id, + upgrade_type: "gold_to_platinum_upgrade", + level: User::Levels::PLATINUM, + }) + + assert_response 200 + assert_equal(User::Levels::PLATINUM, @user.reload.level) + end + end + end + + context "for a gifted upgrade" do + context "of a Member to Gold" do + should "upgrade the user" do + @recipient = create(:member_user) + @purchaser = create(:member_user) + + post_webhook("checkout.session.completed", { + recipient_id: @recipient.id, + purchaser_id: @purchaser.id, + upgrade_type: "gold_upgrade", + level: User::Levels::GOLD, + }) + + assert_response 200 + assert_equal(User::Levels::GOLD, @recipient.reload.level) + end + end + + context "of a Member to Platinum" do + should "upgrade the user" do + @recipient = create(:member_user) + @purchaser = create(:member_user) + + post_webhook("checkout.session.completed", { + recipient_id: @recipient.id, + purchaser_id: @purchaser.id, + upgrade_type: "platinum_upgrade", + level: User::Levels::PLATINUM, + }) + + assert_response 200 + assert_equal(User::Levels::PLATINUM, @recipient.reload.level) + end + end + + context "of a Gold user to Platinum" do + should "upgrade the user" do + @recipient = create(:gold_user) + @purchaser = create(:member_user) + + post_webhook("checkout.session.completed", { + recipient_id: @recipient.id, + purchaser_id: @purchaser.id, + upgrade_type: "gold_to_platinum_upgrade", + level: User::Levels::PLATINUM, + }) + + assert_response 200 + assert_equal(User::Levels::PLATINUM, @recipient.reload.level) + end + end + end + end + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1af778120..ca6a4b4f2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -26,6 +26,7 @@ class ActiveSupport::TestCase include DownloadTestHelper include IqdbTestHelper include UploadTestHelper + extend StripeTestHelper mock_post_version_service! mock_pool_version_service! diff --git a/test/test_helpers/stripe_test_helper.rb b/test/test_helpers/stripe_test_helper.rb new file mode 100644 index 000000000..d1f0f7126 --- /dev/null +++ b/test/test_helpers/stripe_test_helper.rb @@ -0,0 +1,13 @@ +StripeMock.webhook_fixture_path = "test/fixtures/stripe-webhooks" + +module StripeTestHelper + def mock_stripe! + setup do + StripeMock.start + end + + teardown do + StripeMock.stop + end + end +end diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb new file mode 100644 index 000000000..b6d228246 --- /dev/null +++ b/test/unit/user_upgrade_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' + +class UserUpgradeTest < ActiveSupport::TestCase + context "UserUpgrade:" do + context "the #process_upgrade! method" do + setup do + @user = create(:user) + @user_upgrade = UserUpgrade.new(recipient: @user, purchaser: @user, level: User::Levels::GOLD) + end + + should "update the user's level" do + @user_upgrade.process_upgrade! + assert_equal(User::Levels::GOLD, @user.reload.level) + end + + should "log an account upgrade modaction" do + assert_difference("ModAction.user_account_upgrade.count") do + @user_upgrade.process_upgrade! + end + end + + should "send the user a dmail" do + assert_difference("@user.dmails.received.count") do + @user_upgrade.process_upgrade! + end + end + + context "for an upgrade for a user above Platinum level" do + should "not demote the user" do + @user.update!(level: User::Levels::BUILDER) + + assert_raise(User::PrivilegeError) do + @user_upgrade.process_upgrade! + end + + assert_equal(true, @user.reload.is_builder?) + end + end + end + end +end From 74ed2a8b966073cbc67677059a1cde1640929535 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 24 Dec 2020 06:40:20 -0600 Subject: [PATCH 083/132] user upgrades: add UserUpgrade model. Add a model to store the status of user upgrades. * Store the upgrade purchaser and the upgrade receiver (these are different for a gifted upgrade, the same for a self upgrade). * Store the upgrade type: gold, platinum, or gold-to-platinum upgrades. * Store the upgrade status: ** pending: User is still on the Stripe checkout page, no payment received yet. ** processing: User has completed checkout, but the checkout status in Stripe is still 'unpaid'. ** complete: We've received notification from Stripe that the payment has gone through and the user has been upgraded. * Store the Stripe checkout ID, to cross-reference the upgrade record on Danbooru with the checkout record on Stripe. This is the upgrade flow: * When the user clicks the upgrade button on the upgrade page, we call POST /user_upgrades and create a pending UserUpgrade. * We redirect the user to the checkout page on Stripe. * When the user completes checkout on Stripe, Stripe sends us a webhook notification at POST /webhooks/receive. * When we receive the webhook, we check the payment status, and if it's paid we mark the UserUpgrade as complete and upgrade the user. * After Stripe sees that we have successfully processed the webhook, they redirect the user to the /user_upgrades/:id page, where we show the user their upgrade receipt. --- app/controllers/user_upgrades_controller.rb | 7 +- app/models/user.rb | 2 + app/{logical => models}/user_upgrade.rb | 93 +++++---- app/policies/user_upgrade_policy.rb | 9 + app/views/user_upgrades/new.html.erb | 10 +- app/views/user_upgrades/show.html.erb | 47 +++-- config/routes.rb | 2 +- .../20201224101208_create_user_upgrades.rb | 20 ++ db/structure.sql | 88 ++++++++- test/factories/user_upgrade.rb | 38 ++++ .../user_upgrades_controller_test.rb | 176 ++++++++++++++++-- test/functional/webhooks_controller_test.rb | 108 +++++------ test/unit/user_upgrade_test.rb | 66 ++++--- 13 files changed, 502 insertions(+), 164 deletions(-) rename app/{logical => models}/user_upgrade.rb (61%) create mode 100644 app/policies/user_upgrade_policy.rb create mode 100644 db/migrate/20201224101208_create_user_upgrades.rb create mode 100644 test/factories/user_upgrade.rb diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index e2b8729d6..4a0b199c2 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -3,8 +3,8 @@ class UserUpgradesController < ApplicationController respond_to :js, :html def create - @user_upgrade = UserUpgrade.new(recipient: user, purchaser: CurrentUser.user, level: params[:level].to_i) - @checkout = @user_upgrade.create_checkout + @user_upgrade = authorize UserUpgrade.create(recipient: user, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) + @checkout = @user_upgrade.create_checkout! respond_with(@user_upgrade) end @@ -13,7 +13,8 @@ class UserUpgradesController < ApplicationController end def show - authorize User, :upgrade? + @user_upgrade = authorize UserUpgrade.find(params[:id]) + respond_with(@user_upgrade) end def user diff --git a/app/models/user.rb b/app/models/user.rb index 071c42d04..01730294c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -99,6 +99,8 @@ class User < ApplicationRecord has_many :post_votes has_many :post_versions, foreign_key: :updater_id has_many :bans, -> {order("bans.id desc")} + has_many :received_upgrades, class_name: "UserUpgrade", foreign_key: :recipient_id, dependent: :destroy + has_many :purchased_upgrades, class_name: "UserUpgrade", foreign_key: :purchaser_id, dependent: :destroy has_one :recent_ban, -> {order("bans.id desc")}, :class_name => "Ban" has_one :api_key diff --git a/app/logical/user_upgrade.rb b/app/models/user_upgrade.rb similarity index 61% rename from app/logical/user_upgrade.rb rename to app/models/user_upgrade.rb index f9af14708..3fdc81045 100644 --- a/app/logical/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -1,5 +1,18 @@ -class UserUpgrade - attr_reader :recipient, :purchaser, :level +class UserUpgrade < ApplicationRecord + belongs_to :recipient, class_name: "User" + belongs_to :purchaser, class_name: "User" + + enum upgrade_type: { + gold: 0, + platinum: 10, + gold_to_platinum: 20 + }, _suffix: "upgrade" + + enum status: { + pending: 0, + processing: 10, + complete: 20 + } def self.stripe_publishable_key Danbooru.config.stripe_publishable_key @@ -21,51 +34,63 @@ class UserUpgrade platinum_price - gold_price end - def initialize(recipient:, purchaser:, level:) - @recipient, @purchaser, @level = recipient, purchaser, level.to_i - end - - def upgrade_type - if level == User::Levels::GOLD && recipient.level == User::Levels::MEMBER - :gold_upgrade - elsif level == User::Levels::PLATINUM && recipient.level == User::Levels::MEMBER - :platinum_upgrade - elsif level == User::Levels::PLATINUM && recipient.level == User::Levels::GOLD - :gold_to_platinum_upgrade + def level + case upgrade_type + when "gold" + User::Levels::GOLD + when "platinum" + User::Levels::PLATINUM + when "gold_to_platinum" + User::Levels::PLATINUM else - raise ArgumentError, "Invalid upgrade" + raise NotImplementedError end end def upgrade_price case upgrade_type - when :gold_upgrade + when "gold" UserUpgrade.gold_price - when :platinum_upgrade + when "platinum" UserUpgrade.platinum_price - when :gold_to_platinum_upgrade + when "gold_to_platinum" UserUpgrade.gold_to_platinum_price + else + raise NotImplementedError end end def upgrade_description case upgrade_type - when :gold_upgrade + when "gold" "Upgrade to Gold" - when :platinum_upgrade + when "platinum" "Upgrade to Platinum" - when :gold_to_platinum_upgrade + when "gold_to_platinum" "Upgrade Gold to Platinum" + else + raise NotImplementedError end end + def level_string + User.level_string(level) + end + def is_gift? recipient != purchaser end - def process_upgrade! + def process_upgrade!(payment_status) recipient.with_lock do - upgrade_recipient! + return if status == "complete" + + if payment_status == "paid" + upgrade_recipient! + update!(status: :complete) + else + update!(status: :processing) + end end end @@ -74,12 +99,12 @@ class UserUpgrade end concerning :StripeMethods do - def create_checkout - Stripe::Checkout::Session.create( + def create_checkout! + checkout = Stripe::Checkout::Session.create( mode: "payment", - success_url: Routes.user_upgrade_url(user_id: recipient.id), + success_url: Routes.user_upgrade_url(self), cancel_url: Routes.new_user_upgrade_url(user_id: recipient.id), - client_reference_id: "user_#{purchaser.id}", + client_reference_id: "user_upgrade_#{id}", customer_email: recipient.email_address&.address, payment_method_types: ["card"], line_items: [{ @@ -93,6 +118,7 @@ class UserUpgrade quantity: 1, }], metadata: { + user_upgrade_id: id, purchaser_id: purchaser.id, recipient_id: recipient.id, purchaser_name: purchaser.name, @@ -102,6 +128,9 @@ class UserUpgrade level: level, }, ) + + update!(stripe_id: checkout.id) + checkout end class_methods do @@ -122,7 +151,7 @@ class UserUpgrade event = build_event(request) if event.type == "checkout.session.completed" - checkout_session_completed(event) + checkout_session_completed(event.data.object) end end @@ -132,13 +161,9 @@ class UserUpgrade Stripe::Webhook.construct_event(payload, signature, stripe_webhook_secret) end - def checkout_session_completed(event) - recipient = User.find(event.data.object.metadata.recipient_id) - purchaser = User.find(event.data.object.metadata.purchaser_id) - level = event.data.object.metadata.level - - user_upgrade = UserUpgrade.new(recipient: recipient, purchaser: purchaser, level: level) - user_upgrade.process_upgrade! + def checkout_session_completed(checkout) + user_upgrade = UserUpgrade.find(checkout.metadata.user_upgrade_id) + user_upgrade.process_upgrade!(checkout.payment_status) end end end diff --git a/app/policies/user_upgrade_policy.rb b/app/policies/user_upgrade_policy.rb new file mode 100644 index 000000000..78365557f --- /dev/null +++ b/app/policies/user_upgrade_policy.rb @@ -0,0 +1,9 @@ +class UserUpgradePolicy < ApplicationPolicy + def create? + user.is_member? + end + + def show? + record.recipient == user || record.purchaser == user || user.is_owner? + end +end diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 2ed284092..89d7ea4d6 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -101,11 +101,11 @@

    You can pay with a credit or debit card. Safebooru uses Stripe as a payment intermediary so none of your personal information will be stored on the site.

    - <% if user.level < User::Levels::GOLD %> -

    <%= button_to "Upgrade to Gold", user_upgrade_path(user_id: user.id, level: User::Levels::GOLD), remote: true, disable_with: "Redirecting..." %>

    -

    <%= button_to "Upgrade to Platinum", user_upgrade_path(user_id: user.id, level: User::Levels::PLATINUM), remote: true, disable_with: "Redirecting..." %>

    - <% elsif user.level < User::Levels::PLATINUM %> -

    <%= button_to "Upgrade Gold to Platinum", user_upgrade_path(user_id: user.id, level: User::Levels::PLATINUM), remote: true, disable_with: "Redirecting..." %>

    + <% if user.level == User::Levels::MEMBER %> +

    <%= button_to "Upgrade to Gold", user_upgrades_path(user_id: user.id, upgrade_type: "gold"), remote: true, disable_with: "Redirecting..." %>

    +

    <%= button_to "Upgrade to Platinum", user_upgrades_path(user_id: user.id, upgrade_type: "platinum"), remote: true, disable_with: "Redirecting..." %>

    + <% elsif user.level == User::Levels::GOLD %> +

    <%= button_to "Upgrade Gold to Platinum", user_upgrades_path(user_id: user.id, upgrade_type: "gold_to_platinum"), remote: true, disable_with: "Redirecting..." %>

    <% end %> <% else %> diff --git a/app/views/user_upgrades/show.html.erb b/app/views/user_upgrades/show.html.erb index 014421551..7eeddffdf 100644 --- a/app/views/user_upgrades/show.html.erb +++ b/app/views/user_upgrades/show.html.erb @@ -1,22 +1,47 @@ -<% page_title "Account Upgraded" %> +<% page_title "User Upgrade Status" %> <%= render "users/secondary_links" %>
    - <% if flash[:success] %> -

    Congratulations!

    +

    User Upgrade

    - <% if user != CurrentUser.user %> -

    <%= user.name %> is now a <%= user.level_string %> user. Thanks for supporting the site!

    +

    +

      +
    • + Purchased + <%= time_ago_in_words_tagged @user_upgrade.updated_at %> + by <%= link_to_user @user_upgrade.purchaser %> + <% if @user_upgrade.is_gift? %> + for <%= link_to_user @user_upgrade.recipient %> + <% end %> +
    • +
    • + Upgrade Type + <%= @user_upgrade.upgrade_type.humanize %> +
    • +
    • + Status + <%= @user_upgrade.status.humanize %> +
    • +
    +

    + + <% if @user_upgrade.status == "complete" %> + <% if @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.recipient %> +

    <%= link_to_user @user_upgrade.purchaser %> has upgraded your account to <%= @user_upgrade.level_string %>. Enjoy your new account!

    + <% elsif @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.purchaser %> +

    <%= link_to_user @user_upgrade.recipient %> is now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site!

    <% else %> -

    You are now a <%= user.level_string %> user. Thanks for supporting the site!

    +

    You are now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site!

    <% end %> -

    <%= link_to "Go back to #{Danbooru.config.canonical_app_name}", "https://danbooru.donmai.us" %> to start using your new account.

    - <% elsif flash[:error] %> -

    An error occurred!

    -

    <%= flash[:error] %>

    -

    <%= link_to "Try again", new_user_upgrade_path %>

    +

    <%= link_to "Go back to #{Danbooru.config.canonical_app_name}", "https://danbooru.donmai.us" %> to continue using the site.

    + <% else %> + <%= content_for :html_header do %> + + <% end %> + +

    This order is still being processed. You will be notified as soon as the order is complete.

    <% end %>
    diff --git a/config/routes.rb b/config/routes.rb index b027bb3a3..4bceb91fe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -254,7 +254,7 @@ Rails.application.routes.draw do get :custom_style end end - resource :user_upgrade, :only => [:new, :create, :show] + resources :user_upgrades, only: [:new, :create, :show] resources :user_feedbacks, except: [:destroy] resources :user_name_change_requests, only: [:new, :create, :show, :index] resources :webhooks do diff --git a/db/migrate/20201224101208_create_user_upgrades.rb b/db/migrate/20201224101208_create_user_upgrades.rb new file mode 100644 index 000000000..46b44f22a --- /dev/null +++ b/db/migrate/20201224101208_create_user_upgrades.rb @@ -0,0 +1,20 @@ +class CreateUserUpgrades < ActiveRecord::Migration[6.1] + def change + create_table :user_upgrades do |t| + t.timestamps + + t.references :recipient, index: true, null: false + t.references :purchaser, index: true, null: false + t.integer :upgrade_type, index: true, null: false + t.integer :status, index: true, null: false + t.string :stripe_id, index: true, null: true + end + + # Reserve ID space for backfilling old upgrades. + reversible do |dir| + dir.up do + execute "SELECT setval('user_upgrades_id_seq', 25000, false)" + end + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 5f8a9b7da..f57281afa 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -3104,6 +3104,41 @@ CREATE SEQUENCE public.user_name_change_requests_id_seq ALTER SEQUENCE public.user_name_change_requests_id_seq OWNED BY public.user_name_change_requests.id; +-- +-- Name: user_upgrades; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_upgrades ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + recipient_id bigint NOT NULL, + purchaser_id bigint NOT NULL, + upgrade_type integer NOT NULL, + status integer NOT NULL, + stripe_id character varying +); + + +-- +-- Name: user_upgrades_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_upgrades_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_upgrades_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_upgrades_id_seq OWNED BY public.user_upgrades.id; + + -- -- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -4172,6 +4207,13 @@ ALTER TABLE ONLY public.user_feedback ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.user_name_change_requests ALTER COLUMN id SET DEFAULT nextval('public.user_name_change_requests_id_seq'::regclass); +-- +-- Name: user_upgrades id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_upgrades ALTER COLUMN id SET DEFAULT nextval('public.user_upgrades_id_seq'::regclass); + + -- -- Name: users id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4537,6 +4579,14 @@ ALTER TABLE ONLY public.user_name_change_requests ADD CONSTRAINT user_name_change_requests_pkey PRIMARY KEY (id); +-- +-- Name: user_upgrades user_upgrades_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_upgrades + ADD CONSTRAINT user_upgrades_pkey PRIMARY KEY (id); + + -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -7045,6 +7095,41 @@ CREATE INDEX index_user_name_change_requests_on_original_name ON public.user_nam CREATE INDEX index_user_name_change_requests_on_user_id ON public.user_name_change_requests USING btree (user_id); +-- +-- Name: index_user_upgrades_on_purchaser_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_upgrades_on_purchaser_id ON public.user_upgrades USING btree (purchaser_id); + + +-- +-- Name: index_user_upgrades_on_recipient_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_upgrades_on_recipient_id ON public.user_upgrades USING btree (recipient_id); + + +-- +-- Name: index_user_upgrades_on_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_upgrades_on_status ON public.user_upgrades USING btree (status); + + +-- +-- Name: index_user_upgrades_on_stripe_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_upgrades_on_stripe_id ON public.user_upgrades USING btree (stripe_id); + + +-- +-- Name: index_user_upgrades_on_upgrade_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_upgrades_on_upgrade_type ON public.user_upgrades USING btree (upgrade_type); + + -- -- Name: index_users_on_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -7437,6 +7522,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20200816175151'), ('20201201211748'), ('20201213052805'), -('20201219201007'); +('20201219201007'), +('20201224101208'); diff --git a/test/factories/user_upgrade.rb b/test/factories/user_upgrade.rb new file mode 100644 index 000000000..a3b13c4fd --- /dev/null +++ b/test/factories/user_upgrade.rb @@ -0,0 +1,38 @@ +FactoryBot.define do + factory(:user_upgrade) do + recipient { create(:member_user) } + purchaser { recipient } + upgrade_type { "gold" } + status { "pending" } + stripe_id { nil } + + factory(:self_gold_upgrade) do + upgrade_type { "gold" } + end + + factory(:self_platinum_upgrade) do + upgrade_type { "platinum" } + end + + factory(:self_gold_to_platinum_upgrade) do + recipient { create(:gold_user) } + upgrade_type { "gold_to_platinum" } + end + + factory(:gift_gold_upgrade) do + purchaser { create(:user) } + upgrade_type { "gold" } + end + + factory(:gift_platinum_upgrade) do + purchaser { create(:user) } + upgrade_type { "platinum" } + end + + factory(:gift_gold_to_platinum_upgrade) do + recipient { create(:gold_user) } + purchaser { create(:user) } + upgrade_type { "gold_to_platinum" } + end + end +end diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index 58e92a8aa..ba6240730 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -3,38 +3,184 @@ require 'test_helper' class UserUpgradesControllerTest < ActionDispatch::IntegrationTest context "The user upgrades controller" do context "new action" do - should "render" do + should "render for a self upgrade" do + @user = create(:user) + get_auth new_user_upgrade_path, @user + + assert_response :success + end + + should "render for a gift upgrade" do + @recipient = create(:user) + get_auth new_user_upgrade_path(user_id: @recipient.id), create(:user) + + assert_response :success + end + + should "render for an anonymous user" do get new_user_upgrade_path + assert_response :success end end context "show action" do - should "render" do - get_auth user_upgrade_path, create(:user) - assert_response :success + context "for a completed upgrade" do + should "render for a self upgrade" do + @user_upgrade = create(:self_gold_upgrade, status: "complete") + get_auth user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response :success + end + + should "render for a gift upgrade for the purchaser" do + @user_upgrade = create(:gift_gold_upgrade, status: "complete") + get_auth user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response :success + end + + should "render for a gift upgrade for the recipient" do + @user_upgrade = create(:gift_gold_upgrade, status: "complete") + get_auth user_upgrade_path(@user_upgrade), @user_upgrade.recipient + + assert_response :success + end + + should "render for the site owner" do + @user_upgrade = create(:self_gold_upgrade, status: "complete") + get_auth user_upgrade_path(@user_upgrade), create(:owner_user) + + assert_response :success + end + + should "be inaccessible to other users" do + @user_upgrade = create(:self_gold_upgrade, status: "complete") + get_auth user_upgrade_path(@user_upgrade), create(:user) + + assert_response 403 + end + end + + context "for a pending upgrade" do + should "render" do + @user_upgrade = create(:self_gold_upgrade, status: "pending") + get_auth user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response :success + end end end context "create action" do mock_stripe! - context "for a self upgrade to Gold" do - should "redirect the user to the Stripe checkout page" do - user = create(:member_user) - post_auth user_upgrade_path(user_id: user.id), user, params: { level: User::Levels::GOLD }, xhr: true + context "for a self upgrade" do + context "to Gold" do + should "create a pending upgrade" do + @user = create(:member_user) - assert_response :success + post_auth user_upgrades_path(user_id: @user.id), @user, params: { upgrade_type: "gold" }, xhr: true + assert_response :success + + @user_upgrade = @user.purchased_upgrades.last + assert_equal(@user, @user_upgrade.purchaser) + assert_equal(@user, @user_upgrade.recipient) + assert_equal("gold", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end + end + + context "to Platinum" do + should "create a pending upgrade" do + @user = create(:member_user) + + post_auth user_upgrades_path(user_id: @user.id), @user, params: { upgrade_type: "platinum" }, xhr: true + assert_response :success + + @user_upgrade = @user.purchased_upgrades.last + assert_equal(@user, @user_upgrade.purchaser) + assert_equal(@user, @user_upgrade.recipient) + assert_equal("platinum", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end + end + + context "from Gold to Platinum" do + should "create a pending upgrade" do + @user = create(:member_user) + + post_auth user_upgrades_path(user_id: @user.id), @user, params: { upgrade_type: "gold_to_platinum" }, xhr: true + assert_response :success + + @user_upgrade = @user.purchased_upgrades.last + assert_equal(@user, @user_upgrade.purchaser) + assert_equal(@user, @user_upgrade.recipient) + assert_equal("gold_to_platinum", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end end end - context "for a gifted upgrade to Gold" do - should "redirect the user to the Stripe checkout page" do - recipient = create(:member_user) - purchaser = create(:member_user) - post_auth user_upgrade_path(user_id: recipient.id), purchaser, params: { level: User::Levels::GOLD }, xhr: true + context "for a gifted upgrade" do + context "to Gold" do + should "create a pending upgrade" do + @recipient = create(:member_user) + @purchaser = create(:member_user) - assert_response :success + post_auth user_upgrades_path(user_id: @recipient.id), @purchaser, params: { upgrade_type: "gold" }, xhr: true + assert_response :success + + @user_upgrade = @purchaser.purchased_upgrades.last + assert_equal(@purchaser, @user_upgrade.purchaser) + assert_equal(@recipient, @user_upgrade.recipient) + assert_equal("gold", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end + end + + context "to Platinum" do + should "create a pending upgrade" do + @recipient = create(:member_user) + @purchaser = create(:member_user) + + post_auth user_upgrades_path(user_id: @recipient.id), @purchaser, params: { upgrade_type: "platinum" }, xhr: true + assert_response :success + + @user_upgrade = @purchaser.purchased_upgrades.last + assert_equal(@purchaser, @user_upgrade.purchaser) + assert_equal(@recipient, @user_upgrade.recipient) + assert_equal("platinum", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end + end + + context "from Gold to Platinum" do + should "create a pending upgrade" do + @recipient = create(:gold_user) + @purchaser = create(:member_user) + + post_auth user_upgrades_path(user_id: @recipient.id), @purchaser, params: { upgrade_type: "gold_to_platinum" }, xhr: true + assert_response :success + + @user_upgrade = @purchaser.purchased_upgrades.last + assert_equal(@purchaser, @user_upgrade.purchaser) + assert_equal(@recipient, @user_upgrade.recipient) + assert_equal("gold_to_platinum", @user_upgrade.upgrade_type) + assert_equal("pending", @user_upgrade.status) + assert_not_nil(@user_upgrade.stripe_id) + assert_match(/redirectToCheckout/, response.body) + end end end end diff --git a/test/functional/webhooks_controller_test.rb b/test/functional/webhooks_controller_test.rb index afb596be4..dd10f8df8 100644 --- a/test/functional/webhooks_controller_test.rb +++ b/test/functional/webhooks_controller_test.rb @@ -3,8 +3,8 @@ require 'test_helper' class WebhooksControllerTest < ActionDispatch::IntegrationTest mock_stripe! - def post_webhook(*args, **metadata) - event = StripeMock.mock_webhook_event(*args, metadata: metadata) + def post_webhook(*args, payment_status: "paid", **metadata) + event = StripeMock.mock_webhook_event(*args, payment_status: payment_status, metadata: metadata) signature = generate_stripe_signature(event) headers = { "Stripe-Signature": signature } @@ -58,105 +58,83 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest end context "for a checkout.session.completed event" do + context "for completed event with an unpaid payment status" do + should "not upgrade the user" do + @user_upgrade = create(:self_gold_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id, payment_status: "unpaid" }) + + assert_response 200 + assert_equal("processing", @user_upgrade.reload.status) + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.reload.level) + end + end + context "for a self upgrade" do - context "of a Member to Gold" do + context "to Gold" do should "upgrade the user" do - @user = create(:member_user) - - post_webhook("checkout.session.completed", { - recipient_id: @user.id, - purchaser_id: @user.id, - upgrade_type: "gold_upgrade", - level: User::Levels::GOLD, - }) + @user_upgrade = create(:self_gold_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::GOLD, @user.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::GOLD, @user_upgrade.recipient.reload.level) end end - context "of a Member to Platinum" do + context "to Platinum" do should "upgrade the user" do - @user = create(:member_user) - - post_webhook("checkout.session.completed", { - recipient_id: @user.id, - purchaser_id: @user.id, - upgrade_type: "platinum_upgrade", - level: User::Levels::PLATINUM, - }) + @user_upgrade = create(:self_platinum_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::PLATINUM, @user.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::PLATINUM, @user_upgrade.recipient.reload.level) end end - context "of a Gold user to Platinum" do + context "from Gold to Platinum" do should "upgrade the user" do - @user = create(:gold_user) - - post_webhook("checkout.session.completed", { - recipient_id: @user.id, - purchaser_id: @user.id, - upgrade_type: "gold_to_platinum_upgrade", - level: User::Levels::PLATINUM, - }) + @user_upgrade = create(:self_gold_to_platinum_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::PLATINUM, @user.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::PLATINUM, @user_upgrade.recipient.reload.level) end end end context "for a gifted upgrade" do - context "of a Member to Gold" do + context "to Gold" do should "upgrade the user" do - @recipient = create(:member_user) - @purchaser = create(:member_user) - - post_webhook("checkout.session.completed", { - recipient_id: @recipient.id, - purchaser_id: @purchaser.id, - upgrade_type: "gold_upgrade", - level: User::Levels::GOLD, - }) + @user_upgrade = create(:gift_gold_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::GOLD, @recipient.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::GOLD, @user_upgrade.recipient.reload.level) end end - context "of a Member to Platinum" do + context "to Platinum" do should "upgrade the user" do - @recipient = create(:member_user) - @purchaser = create(:member_user) - - post_webhook("checkout.session.completed", { - recipient_id: @recipient.id, - purchaser_id: @purchaser.id, - upgrade_type: "platinum_upgrade", - level: User::Levels::PLATINUM, - }) + @user_upgrade = create(:gift_platinum_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::PLATINUM, @recipient.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::PLATINUM, @user_upgrade.recipient.reload.level) end end - context "of a Gold user to Platinum" do + context "from Gold to Platinum" do should "upgrade the user" do - @recipient = create(:gold_user) - @purchaser = create(:member_user) - - post_webhook("checkout.session.completed", { - recipient_id: @recipient.id, - purchaser_id: @purchaser.id, - upgrade_type: "gold_to_platinum_upgrade", - level: User::Levels::PLATINUM, - }) + @user_upgrade = create(:gift_gold_to_platinum_upgrade) + post_webhook("checkout.session.completed", { user_upgrade_id: @user_upgrade.id }) assert_response 200 - assert_equal(User::Levels::PLATINUM, @recipient.reload.level) + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::PLATINUM, @user_upgrade.recipient.reload.level) end end end diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb index b6d228246..070fa2f48 100644 --- a/test/unit/user_upgrade_test.rb +++ b/test/unit/user_upgrade_test.rb @@ -3,37 +3,45 @@ require 'test_helper' class UserUpgradeTest < ActiveSupport::TestCase context "UserUpgrade:" do context "the #process_upgrade! method" do - setup do - @user = create(:user) - @user_upgrade = UserUpgrade.new(recipient: @user, purchaser: @user, level: User::Levels::GOLD) - end - - should "update the user's level" do - @user_upgrade.process_upgrade! - assert_equal(User::Levels::GOLD, @user.reload.level) - end - - should "log an account upgrade modaction" do - assert_difference("ModAction.user_account_upgrade.count") do - @user_upgrade.process_upgrade! - end - end - - should "send the user a dmail" do - assert_difference("@user.dmails.received.count") do - @user_upgrade.process_upgrade! - end - end - - context "for an upgrade for a user above Platinum level" do - should "not demote the user" do - @user.update!(level: User::Levels::BUILDER) - - assert_raise(User::PrivilegeError) do - @user_upgrade.process_upgrade! + context "for a self upgrade" do + context "to Gold" do + setup do + @user_upgrade = create(:self_gold_upgrade) end - assert_equal(true, @user.reload.is_builder?) + should "update the user's level if the payment status is paid" do + @user_upgrade.process_upgrade!("paid") + + assert_equal(User::Levels::GOLD, @user_upgrade.recipient.level) + assert_equal("complete", @user_upgrade.status) + end + + should "not update the user's level if the payment is unpaid" do + @user_upgrade.process_upgrade!("unpaid") + + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.level) + assert_equal("processing", @user_upgrade.status) + end + + should "not update the user's level if the upgrade status is complete" do + @user_upgrade.update!(status: "complete") + @user_upgrade.process_upgrade!("paid") + + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.level) + assert_equal("complete", @user_upgrade.status) + end + + should "log an account upgrade modaction" do + assert_difference("ModAction.user_account_upgrade.count") do + @user_upgrade.process_upgrade!("paid") + end + end + + should "send the recipient a dmail" do + assert_difference("@user_upgrade.recipient.dmails.received.count") do + @user_upgrade.process_upgrade!("paid") + end + end end end end From 058d71aa30f00ec0d13223174d76bc70b1273807 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 24 Dec 2020 20:32:20 -0600 Subject: [PATCH 084/132] user upgrades: send dmail to purchaser for gifted upgrades. * Refactor to move upgrade logic from UserPromotion to UserUpgrade. * Send the recipient and the purchaser of a gifted upgrade separate dmail notifications. --- app/logical/user_promotion.rb | 12 +++------ app/models/user_upgrade.rb | 51 +++++++++++++++++++++++++++-------- db/populate.rb | 4 +-- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/app/logical/user_promotion.rb b/app/logical/user_promotion.rb index 6420db5e3..2ee8ee685 100644 --- a/app/logical/user_promotion.rb +++ b/app/logical/user_promotion.rb @@ -1,13 +1,12 @@ class UserPromotion - attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts, :is_upgrade + attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts - def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil, is_upgrade: false) + def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil) @user = user @promoter = promoter @new_level = new_level.to_i @can_upload_free = can_upload_free @can_approve_posts = can_approve_posts - @is_upgrade = is_upgrade end def promote! @@ -21,7 +20,7 @@ class UserPromotion user.can_approve_posts = can_approve_posts unless can_approve_posts.nil? user.inviter = promoter - create_user_feedback unless is_upgrade + create_user_feedback create_dmail create_mod_actions @@ -40,8 +39,7 @@ class UserPromotion end if user.level_changed? - category = is_upgrade ? :user_account_upgrade : :user_level_change - ModAction.log(%{"#{user.name}":#{Routes.user_path(user)} level changed #{user.level_string_was} -> #{user.level_string}}, category, promoter) + ModAction.log(%{"#{user.name}":#{Routes.user_path(user)} level changed #{user.level_string_was} -> #{user.level_string}}, :user_level_change, promoter) end end @@ -54,8 +52,6 @@ class UserPromotion raise User::PrivilegeError, "You can't promote other users to your rank or above" elsif user.level >= promoter.level raise User::PrivilegeError, "You can't promote or demote other users at your rank or above" - elsif is_upgrade && user.is_builder? - raise User::PrivilegeError, "You can't upgrade a user that is above Platinum level" end end diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 3fdc81045..5863d94a8 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -81,21 +81,50 @@ class UserUpgrade < ApplicationRecord recipient != purchaser end - def process_upgrade!(payment_status) - recipient.with_lock do - return if status == "complete" + concerning :UpgradeMethods do + def process_upgrade!(payment_status) + recipient.with_lock do + return if status == "complete" - if payment_status == "paid" - upgrade_recipient! - update!(status: :complete) - else - update!(status: :processing) + if payment_status == "paid" + upgrade_recipient! + create_mod_action! + dmail_recipient! + dmail_purchaser! + update!(status: :complete) + else + update!(status: :processing) + end end end - end - def upgrade_recipient! - recipient.promote_to!(level, User.system, is_upgrade: true) + def upgrade_recipient! + recipient.update!(level: level, inviter: User.system) + end + + def create_mod_action! + ModAction.log(%{"#{recipient.name}":#{Routes.user_path(recipient)} level changed #{User.level_string(recipient.level_before_last_save)} -> #{recipient.level_string}}, :user_account_upgrade, purchaser) + end + + def dmail_recipient! + if is_gift? + body = "Congratulations, your account has been upgraded to #{level_string} by <@#{purchaser.name}>. Enjoy!" + else + body = "You are now a #{level_string} user. Thanks for supporting #{Danbooru.config.canonical_app_name}!" + end + + title = "You have been upgraded to #{level_string}!" + Dmail.create_automated(to: recipient, title: title, body: body) + end + + def dmail_purchaser! + return unless is_gift? + + title = "#{recipient.name} has been upgraded to #{level_string}!" + body = "<@#{recipient.name}> is now a #{level_string} user. Thanks for supporting #{Danbooru.config.canonical_app_name}!" + + Dmail.create_automated(to: purchaser, title: title, body: body) + end end concerning :StripeMethods do diff --git a/db/populate.rb b/db/populate.rb index 7a66a7080..76a517ed3 100644 --- a/db/populate.rb +++ b/db/populate.rb @@ -68,14 +68,14 @@ if User.count == 0 :password => "password1", :password_confirmation => "password1" ) - newuser.promote_to!(User::Levels::BUILDER, :can_upload_free => true, :is_upgrade => true, :skip_dmail => true) + newuser.promote_to!(User::Levels::BUILDER, :can_upload_free => true, :skip_dmail => true) newuser = User.create( :name => "approver", :password => "password1", :password_confirmation => "password1" ) - newuser.promote_to!(User::Levels::BUILDER, :can_approve_posts => true, :is_upgrade => true, :skip_dmail => true) + newuser.promote_to!(User::Levels::BUILDER, :can_approve_posts => true, :skip_dmail => true) end 0.upto(10) do |i| From 069231a33ba9232730f3975f1ae5314451286c29 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 00:27:08 -0600 Subject: [PATCH 085/132] user upgrades: update upgrade landing page. * Add a frequently asked questions section. * Add nicer looking upgrade buttons. * Format the page nicer. * Prevent users from attempting invalid upgrades on users that are already Platinum or above. --- app/controllers/user_upgrades_controller.rb | 11 +- app/javascript/src/styles/base/020_base.scss | 11 + app/javascript/src/styles/base/040_colors.css | 18 +- .../src/styles/specific/user_upgrades.scss | 83 ++++-- app/models/user_upgrade.rb | 8 + app/policies/user_upgrade_policy.rb | 4 + app/views/user_upgrades/new.html.erb | 261 +++++++++++------- app/views/users/_statistics.html.erb | 9 +- .../user_upgrades_controller_test.rb | 25 +- 9 files changed, 283 insertions(+), 147 deletions(-) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 4a0b199c2..62e4cee10 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -1,15 +1,18 @@ class UserUpgradesController < ApplicationController - helper_method :user respond_to :js, :html def create - @user_upgrade = authorize UserUpgrade.create(recipient: user, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) + @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) @checkout = @user_upgrade.create_checkout! respond_with(@user_upgrade) end def new + @user_upgrade = authorize UserUpgrade.new(recipient: recipient, purchaser: CurrentUser.user) + @recipient = @user_upgrade.recipient + + respond_with(@user_upgrade) end def show @@ -17,7 +20,9 @@ class UserUpgradesController < ApplicationController respond_with(@user_upgrade) end - def user + private + + def recipient if params[:user_id] User.find(params[:user_id]) else diff --git a/app/javascript/src/styles/base/020_base.scss b/app/javascript/src/styles/base/020_base.scss index 00180c2c6..3230c5db2 100644 --- a/app/javascript/src/styles/base/020_base.scss +++ b/app/javascript/src/styles/base/020_base.scss @@ -118,6 +118,17 @@ table tfoot { margin-top: 2em; } +details { + border-bottom: 1px solid var(--details-border); + + summary { + cursor: pointer; + user-select: none; + outline: none; + line-height: 2em; + } +} + .fineprint { color: var(--muted-text-color); font-style: italic; diff --git a/app/javascript/src/styles/base/040_colors.css b/app/javascript/src/styles/base/040_colors.css index 5dc30e914..4cf8a227e 100644 --- a/app/javascript/src/styles/base/040_colors.css +++ b/app/javascript/src/styles/base/040_colors.css @@ -35,9 +35,12 @@ --quick-search-form-background: var(--body-background-color); + --user-upgrade-basic-background-color: #F5F5FF; --user-upgrade-gold-background-color: #FFF380; --user-upgrade-platinum-background-color: #EEE; - --user-upgrade-table-row-hover-background-color: #FEF; + --user-upgrade-button-text-color: white; + --user-upgrade-button-background-color: var(--link-color); + --user-upgrade-button-hover-background-color: hsl(213, 100%, 40%); --table-header-border: 2px solid #666; --table-row-border: 1px solid #CCC; @@ -72,6 +75,8 @@ --comment-sticky-background-color: var(--subnav-menu-background-color); + --details-border: #DDD; + --post-tooltip-background-color: var(--body-background-color); --post-tooltip-border-color: hsla(210, 100%, 3%, 0.15); --post-tooltip-box-shadow: 0 4px 14px -2px hsla(210, 100%, 3%, 0.10); @@ -157,7 +162,8 @@ --bulk-update-request-failed-color: red; --login-link-color: #E00; - --footer-border: 1px solid #EEE; + --footer-border: 1px solid #DDD; + --details-border: #DDD; --jquery-ui-widget-content-background: var(--body-background-color); --jquery-ui-widget-content-text-color: var(--text-color); @@ -255,6 +261,7 @@ body[data-current-user-theme="dark"] { --subnav-menu-background-color: var(--grey-2); --responsive-menu-background-color: var(--grey-3); --footer-border: 1px solid var(--grey-3); + --details-border: var(--grey-3); --table-header-border: 2px solid var(--grey-3); --table-even-row-background: var(--grey-2); @@ -419,9 +426,12 @@ body[data-current-user-theme="dark"] { --uploads-dropzone-progress-bar-foreground-color: var(--link-color); --uploads-dropzone-progress-bar-background-color: var(--link-hover-color); - --user-upgrade-gold-background-color: var(--yellow-0); + --user-upgrade-basic-background-color: var(--grey-2); + --user-upgrade-gold-background-color: var(--indigo-0); --user-upgrade-platinum-background-color: var(--blue-0); - --user-upgrade-table-row-hover-background-color: transparent; + --user-upgrade-button-text-color: white; + --user-upgrade-button-background-color: var(--link-color); + --user-upgrade-button-hover-background-color: var(--link-hover-color); --wiki-page-other-name-background-color: var(--grey-3); --wiki-page-versions-diff-del-background: var(--red-0); diff --git a/app/javascript/src/styles/specific/user_upgrades.scss b/app/javascript/src/styles/specific/user_upgrades.scss index 2ab6b4625..b32fdf483 100644 --- a/app/javascript/src/styles/specific/user_upgrades.scss +++ b/app/javascript/src/styles/specific/user_upgrades.scss @@ -1,43 +1,66 @@ div#c-user-upgrades { div#a-new { - form.stripe { - display: inline; - } + margin: 0 auto; - div.section { - margin-bottom: 2em; - } - - div#feature-comparison { - overflow: hidden; + * { margin-bottom: 1em; + } - table { - width: 100%; + h1 { + text-align: center; + } - colgroup { - width: 10em; - } + .login-button, form.button_to input[type="submit"] { + display: inline-block; - colgroup#gold { - background-color: var(--user-upgrade-gold-background-color); - } + color: var(--user-upgrade-button-text-color); + background-color: var(--user-upgrade-button-background-color); - colgroup#platinum { - background-color: var(--user-upgrade-platinum-background-color); - } + border: none; + border-radius: 4px; + padding: 0.75em; - td, th { - text-align: center; - vertical-align: top; - padding: 0.5em 0; - } + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); - tbody { - tr:hover { - background-color: var(--user-upgrade-table-row-hover-background-color); - } - } + &:hover:not([disabled]) { + background-color: var(--user-upgrade-button-hover-background-color); + box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12) + } + + &[disabled] { + background-color: grey; + cursor: default; + } + } + + table#feature-comparison { + width: 100%; + + th { + font-weight: bold; + } + + colgroup { + width: 10em; + } + + colgroup#basic { + background-color: var(--user-upgrade-basic-background-color); + } + + colgroup#gold { + background-color: var(--user-upgrade-gold-background-color); + } + + colgroup#platinum { + background-color: var(--user-upgrade-platinum-background-color); + } + + td, th { + text-align: center; + vertical-align: top; + padding: 0.5em 0; } } } diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 5863d94a8..41aa69c6e 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -14,6 +14,14 @@ class UserUpgrade < ApplicationRecord complete: 20 } + def self.enabled? + stripe_secret_key.present? && stripe_publishable_key.present? && stripe_webhook_secret.present? + end + + def self.stripe_secret_key + Danbooru.config.stripe_secret_key + end + def self.stripe_publishable_key Danbooru.config.stripe_publishable_key end diff --git a/app/policies/user_upgrade_policy.rb b/app/policies/user_upgrade_policy.rb index 78365557f..a6568f5db 100644 --- a/app/policies/user_upgrade_policy.rb +++ b/app/policies/user_upgrade_policy.rb @@ -3,6 +3,10 @@ class UserUpgradePolicy < ApplicationPolicy user.is_member? end + def new? + UserUpgrade.enabled? + end + def show? record.recipient == user || record.purchaser == user || user.is_owner? end diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 89d7ea4d6..b5d278fd9 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -6,116 +6,165 @@
    -

    Upgrade Account

    - <% unless params[:user_id] %> -

    Want more searching power? Upgrade your account and become a power user of the best database of anime artwork on the internet.

    + <% if @user_upgrade.is_gift? %> +

    Gift Account Upgrade

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    BasicGoldPlatinum
    CostFree - <%= cents_to_usd(UserUpgrade.gold_price) %> -
    One time fee
    -
    - <%= cents_to_usd(UserUpgrade.platinum_price) %> -
    One time fee
    -
    Tag Limit2<%= Danbooru.config.base_tag_query_limit %><%= Danbooru.config.base_tag_query_limit*2 %>
    Favorite Limit10,00020,000Unlimited
    Favorite Groups3510
    Page Limit1,0002,0005,000
    Saved Searches2502501,000
    See Hidden TagsNoYesYes
    Search Timeout3 sec6 sec9 sec
    -
    + <% if @user_upgrade.recipient.is_platinum? %> +

    <%= link_to_user @recipient %> is already above Platinum level and can't be upgraded!

    + <% else %> +
    You are gifting this upgrade to <%= link_to_user @user_upgrade.recipient %>.
    + <% end %> + <% else %> +

    Upgrade Account

    + +

    Upgrading your account gives you exclusive benefits and helps support + <%= Danbooru.config.canonical_app_name %>. Your support helps keep the + site ad-free for everyone!

    + +

    You can also gift an account upgrade to someone else. Just go to + their profile page and look for a "Gift Upgrade" link.

    <% end %> -
    - <% if params[:user_id] %> -

    You are gifting this account upgrade to <%= link_to user.pretty_name, user_path(params[:user_id]) %>.

    - <% else %> -

    You can also upgrade someone else's account for the same price. The easiest way is to go to their profile page and look for a "Gift Upgrade" link.

    - <% end %> -
    - - <% if Danbooru.config.stripe_publishable_key %> - <% if CurrentUser.is_anonymous? %> -

    <%= link_to "Sign up", new_user_path %> or <%= link_to "login", login_path(url: new_user_upgrade_path) %> first to upgrade your account.

    - <% elsif CurrentUser.safe_mode? %> -
    -

    You can pay with a credit or debit card. Safebooru uses Stripe - as a payment intermediary so none of your personal information will be stored on the site.

    - - <% if user.level == User::Levels::MEMBER %> -

    <%= button_to "Upgrade to Gold", user_upgrades_path(user_id: user.id, upgrade_type: "gold"), remote: true, disable_with: "Redirecting..." %>

    -

    <%= button_to "Upgrade to Platinum", user_upgrades_path(user_id: user.id, upgrade_type: "platinum"), remote: true, disable_with: "Redirecting..." %>

    - <% elsif user.level == User::Levels::GOLD %> -

    <%= button_to "Upgrade Gold to Platinum", user_upgrades_path(user_id: user.id, upgrade_type: "gold_to_platinum"), remote: true, disable_with: "Redirecting..." %>

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% if @user_upgrade.purchaser.is_anonymous? %> + + + + <% elsif @recipient.level == User::Levels::MEMBER %> + + + + <% elsif @recipient.level == User::Levels::GOLD %> + + + + <% else %> + + + <% end %> - - <% else %> -
    -

    You can pay with a credit or debit card on - <%= link_to "Safebooru", new_user_upgrade_url(user_id: user.id, host: "safebooru.donmai.us", protocol: "https") %>. - Your account will then also be upgraded on Danbooru. You can login to - Safebooru with the same username and password you use on Danbooru.

    -
    - <% end %> - <% end %> + + +
    BasicGoldPlatinum
    Free + <%= cents_to_usd(UserUpgrade.gold_price) %> +
    One time fee
    +
    + <%= cents_to_usd(UserUpgrade.platinum_price) %> +
    One time fee
    +
    Tag Limit2<%= Danbooru.config.base_tag_query_limit %><%= Danbooru.config.base_tag_query_limit*2 %>
    See Hidden TagsNoYesYes
    Page Limit1,0002,0005,000
    Favorite Limit10,00020,000Unlimited
    Favorite Groups3510
    Saved Searches2502501,000
    Search Timeout3 sec6 sec9 sec
    <%= link_to "Login", login_path(url: new_user_upgrade_path), class: "login-button" %><%= link_to "Get #{Danbooru.config.canonical_app_name} Gold", login_path(url: new_user_upgrade_path), class: "login-button" %><%= link_to "Get #{Danbooru.config.canonical_app_name} Platinum", login_path(url: new_user_upgrade_path), class: "login-button" %><%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold"), remote: true, disable_with: "Redirecting..." %><%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "platinum"), remote: true, disable_with: "Redirecting..." %><%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %><%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold_to_platinum"), remote: true, disable_with: "Redirecting..." %><%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %><%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", nil, disabled: true %>
    + +

    Frequently Asked Questions

    + +
    +
    + What are the benefits of <%= Danbooru.config.canonical_app_name %> Gold? + +

    <%= Danbooru.config.canonical_app_name %> Gold lets you do more + complicated searches, and it lets you see hidden tags that non-Gold users + can't see. You can search more tags at once, browser deeper in search + results, and also keep more favorites, favorite groups, and saved searches.

    +
    + +
    + What are the benefits of <%= Danbooru.config.canonical_app_name %> Platinum? + +

    Platinum is like Gold, but it lets you search even more tags at once, + and keep even more favorites, favorite groups, and saved searches.

    +
    + +
    + What payment methods do you support? + +

    We support all major credit and debit cards, including international + cards. Payments are securely handled by Stripe. + We don't support PayPal or Bitcoin at this time.

    +
    + +
    + Is this a subscription? + +

    No, this is not a subscription. This is a one-time payment. You pay + only once and keep the upgrade forever.

    +
    + +
    + If I upgrade to Gold first, can I upgrade to Platinum later? + +

    Yes, if you have a Gold account, you can always upgrade to a Platinum + account later. You don't have to pay full price to upgrade from Gold to + Platinum. You only have to pay the difference.

    +
    + +
    + What is your refund policy? + +

    You can <%= link_to "contact us", contact_path %> to request a refund + for any reason within 48 hours of your purchase.

    +
    +
    diff --git a/app/views/users/_statistics.html.erb b/app/views/users/_statistics.html.erb index fe708c18c..581028cbd 100644 --- a/app/views/users/_statistics.html.erb +++ b/app/views/users/_statistics.html.erb @@ -75,8 +75,13 @@ Level <%= user.level_string %> - <% if CurrentUser.user == user && !CurrentUser.is_gold? %> - (<%= link_to "upgrade", new_user_upgrade_path %>) + + <% if !user.is_platinum? %> + <% if CurrentUser.user == user %> + (<%= link_to "Upgrade account", new_user_upgrade_path %>) + <% else %> + (<%= link_to "Gift upgrade", new_user_upgrade_path(user_id: user.id) %>) + <% end %> <% end %> diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index ba6240730..4f466c86e 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -3,20 +3,41 @@ require 'test_helper' class UserUpgradesControllerTest < ActionDispatch::IntegrationTest context "The user upgrades controller" do context "new action" do - should "render for a self upgrade" do + should "render for a self upgrade to Gold" do @user = create(:user) get_auth new_user_upgrade_path, @user assert_response :success end - should "render for a gift upgrade" do + should "render for a self upgrade to Platinum" do + @user = create(:gold_user) + get_auth new_user_upgrade_path, @user + + assert_response :success + end + + should "render for a gifted upgrade to Gold" do @recipient = create(:user) get_auth new_user_upgrade_path(user_id: @recipient.id), create(:user) assert_response :success end + should "render for a gifted upgrade to Platinum" do + @recipient = create(:gold_user) + get_auth new_user_upgrade_path(user_id: @recipient.id), create(:user) + + assert_response :success + end + + should "render for an invalid gifted upgrade to a user who is already Platinum" do + @recipient = create(:platinum_user) + get_auth new_user_upgrade_path(user_id: @recipient.id), create(:user) + + assert_response :success + end + should "render for an anonymous user" do get new_user_upgrade_path From 96f08b78c5b47dface6660b1b20b1c9c68e54628 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 00:37:15 -0600 Subject: [PATCH 086/132] /contact: update contact page with more contact methods. --- app/helpers/application_helper.rb | 5 +++-- app/views/static/contact.html.erb | 9 ++++++--- test/functional/static_controller_test.rb | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b33c763d1..57f4819c0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -189,7 +189,7 @@ module ApplicationHelper to_sentence(links, **options) end - def link_to_user(user) + def link_to_user(user, text = nil) return "anonymous" if user.blank? user_class = "user user-#{user.level_string.downcase}" @@ -197,8 +197,9 @@ module ApplicationHelper user_class += " user-post-uploader" if user.can_upload_free? user_class += " user-banned" if user.is_banned? + text = user.pretty_name if text.blank? data = { "user-id": user.id, "user-name": user.name, "user-level": user.level } - link_to(user.pretty_name, user_path(user), class: user_class, data: data) + link_to(text, user, class: user_class, data: data) end def mod_link_to_user(user, positive_or_negative) diff --git a/app/views/static/contact.html.erb b/app/views/static/contact.html.erb index edefb96a5..95b3a17c6 100644 --- a/app/views/static/contact.html.erb +++ b/app/views/static/contact.html.erb @@ -4,8 +4,11 @@

    Contact

    -

    Questions & Comments

    - -

    You can reach the administrator of this site at <%= mail_to Danbooru.config.contact_email, nil, :encode => :hex %>.

    +

    + You can contact the administrator of this site by + <%= link_to "sending a private message", new_dmail_path(dmail: { to_name: User.owner.name }) %> to <%= link_to_user User.owner, "@#{User.owner.name}" %>, + by messaging @<%= User.owner.name %> on the <%= link_to "#{Danbooru.config.canonical_app_name} Discord", Danbooru.config.discord_server_url %>, + or by sending an email to <%= mail_to Danbooru.config.contact_email, nil, encode: :hex %>. +

    diff --git a/test/functional/static_controller_test.rb b/test/functional/static_controller_test.rb index dff4ff680..13b99b53c 100644 --- a/test/functional/static_controller_test.rb +++ b/test/functional/static_controller_test.rb @@ -94,6 +94,8 @@ class StaticControllerTest < ActionDispatch::IntegrationTest context "contact action" do should "work" do + create(:owner_user) + get contact_path assert_response :success end From 2d50ba6fd56ad4664a4a9a1d31926192bbeec91f Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 00:59:48 -0600 Subject: [PATCH 087/132] posts: fix /posts/random route. Fixup for 039ccfa3a. --- config/routes.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 4bceb91fe..1f39cbf94 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,8 @@ Rails.application.routes.draw do - resources :posts, only: [:index, :show, :update, :destroy] + resources :posts, only: [:index, :show, :update, :destroy] do + get :random, on: :collection + end + resources :autocomplete, only: [:index] # XXX This comes *after* defining posts above because otherwise the paginator @@ -187,9 +190,6 @@ Rails.application.routes.draw do member { put :revert } end resource :votes, controller: "post_votes", only: [:create, :destroy], as: "post_votes" - collection do - get :random - end member do put :revert put :copy_notes From e030a07816fec47c002eb7fc21edfb7bd3a0f62f Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 01:21:54 -0600 Subject: [PATCH 088/132] user upgrades: add index action. --- app/controllers/user_upgrades_controller.rb | 7 ++++ app/models/user_upgrade.rb | 24 ++++++++++++ app/views/user_upgrades/index.html.erb | 38 +++++++++++++++++++ config/routes.rb | 2 +- .../user_upgrades_controller_test.rb | 28 ++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 app/views/user_upgrades/index.html.erb diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 62e4cee10..80c8fa303 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -15,6 +15,13 @@ class UserUpgradesController < ApplicationController respond_with(@user_upgrade) end + def index + @user_upgrades = authorize UserUpgrade.visible(CurrentUser.user).paginated_search(params, count_pages: true) + @user_upgrades = @user_upgrades.includes(:recipient, :purchaser) if request.format.html? + + respond_with(@user_upgrades) + end + def show @user_upgrade = authorize UserUpgrade.find(params[:id]) respond_with(@user_upgrade) diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 41aa69c6e..acb2eb32b 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -14,6 +14,9 @@ class UserUpgrade < ApplicationRecord complete: 20 } + scope :gifted, -> { where("recipient_id != purchaser_id") } + scope :self_upgrade, -> { where("recipient_id = purchaser_id") } + def self.enabled? stripe_secret_key.present? && stripe_publishable_key.present? && stripe_webhook_secret.present? end @@ -89,6 +92,27 @@ class UserUpgrade < ApplicationRecord recipient != purchaser end + def self.visible(user) + if user.is_owner? + all + else + where(recipient: user).or(where(purchaser: user)) + end + end + + def self.search(params) + q = search_attributes(params, :id, :created_at, :updated_at, :upgrade_type, :status, :stripe_id, :recipient, :purchaser) + + if params[:is_gifted].to_s.truthy? + q = q.gifted + elsif params[:is_gifted].to_s.falsy? + q = q.self_upgrade + end + + q = q.apply_default_order(params) + q + end + concerning :UpgradeMethods do def process_upgrade!(payment_status) recipient.with_lock do diff --git a/app/views/user_upgrades/index.html.erb b/app/views/user_upgrades/index.html.erb new file mode 100644 index 000000000..611ea071e --- /dev/null +++ b/app/views/user_upgrades/index.html.erb @@ -0,0 +1,38 @@ +
    +
    + <%= search_form_for(user_upgrades_path) do |f| %> + <%= f.input :recipient_name, label: "Recipient", input_html: { value: params[:search][:recipient_name], data: { autocomplete: "user" } } %> + <%= f.input :purchaser_name, label: "Purchaser", input_html: { value: params[:search][:purchaser_name], data: { autocomplete: "user" } } %> + <%= f.input :upgrade_type, collection: UserUpgrade.upgrade_types, include_blank: true, selected: params[:search][:upgrade_type] %> + <%= f.input :status, collection: UserUpgrade.statuses, include_blank: true, selected: params[:search][:status] %> + <%= f.input :is_gifted, label: "Gifted?", as: :select, include_blank: true, selected: params[:search][:is_gifted] %> + <%= f.submit "Search" %> + <% end %> + + <%= table_for @user_upgrades, class: "striped autofit" do |t| %> + <% t.column "Recipient" do |user_upgrade| %> + <%= link_to_user user_upgrade.recipient %> + <% end %> + + <% t.column "Purchaser" do |user_upgrade| %> + <%= link_to_user user_upgrade.purchaser %> + <% end %> + + <% t.column :upgrade_type do |user_upgrade| %> + <%= user_upgrade.upgrade_type.humanize %> + <% end %> + + <% t.column "Gifted?" do |user_upgrade| %> + <%= "Yes" if user_upgrade.is_gift? %> + <% end %> + + <% t.column :status %> + + <% t.column "Updated" do |artist| %> + <%= time_ago_in_words_tagged(artist.updated_at) %> + <% end %> + <% end %> + + <%= numbered_paginator(@user_upgrades) %> +
    +
    diff --git a/config/routes.rb b/config/routes.rb index 1f39cbf94..a20f927f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -254,7 +254,7 @@ Rails.application.routes.draw do get :custom_style end end - resources :user_upgrades, only: [:new, :create, :show] + resources :user_upgrades, only: [:new, :create, :show, :index] resources :user_feedbacks, except: [:destroy] resources :user_name_change_requests, only: [:new, :create, :show, :index] resources :webhooks do diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index 4f466c86e..75ff4f2f1 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -45,6 +45,34 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end end + context "index action" do + setup do + @self_upgrade = create(:self_gold_upgrade) + @gift_upgrade = create(:gift_gold_upgrade) + end + + should "show the purchaser's upgrades to the purchaser" do + get_auth user_upgrades_path, @gift_upgrade.purchaser + + assert_response :success + assert_select "#user-upgrade-#{@gift_upgrade.id}", count: 1 + end + + should "show the recipient's upgrades to the recipient" do + get_auth user_upgrades_path, @gift_upgrade.recipient + + assert_response :success + assert_select "#user-upgrade-#{@gift_upgrade.id}", count: 1 + end + + should "not show upgrades to unrelated users" do + get_auth user_upgrades_path, create(:user) + + assert_response :success + assert_select "#user-upgrade-#{@gift_upgrade.id}", count: 0 + end + end + context "show action" do context "for a completed upgrade" do should "render for a self upgrade" do From d9db32640afb4352733cd52edc10f2ba615eea70 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 02:01:42 -0600 Subject: [PATCH 089/132] user upgrades: fix checkout form leaking recipient's email. The checkout form should be prefilled with the purchaser's email address, not the recipient's. --- app/models/user_upgrade.rb | 2 +- test/unit/user_upgrade_test.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index acb2eb32b..55c388a46 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -166,7 +166,7 @@ class UserUpgrade < ApplicationRecord success_url: Routes.user_upgrade_url(self), cancel_url: Routes.new_user_upgrade_url(user_id: recipient.id), client_reference_id: "user_upgrade_#{id}", - customer_email: recipient.email_address&.address, + customer_email: purchaser.email_address&.address, payment_method_types: ["card"], line_items: [{ price_data: { diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb index 070fa2f48..93666aebb 100644 --- a/test/unit/user_upgrade_test.rb +++ b/test/unit/user_upgrade_test.rb @@ -45,5 +45,19 @@ class UserUpgradeTest < ActiveSupport::TestCase end end end + + context "the #create_checkout! method" do + context "for a gifted upgrade" do + context "to Gold" do + should "prefill the Stripe checkout page with the purchaser's email address" do + @user = create(:user, email_address: build(:email_address)) + @user_upgrade = create(:gift_gold_upgrade, purchaser: @user) + @checkout = @user_upgrade.create_checkout! + + assert_equal(@user.email_address.address, @checkout.customer_email) + end + end + end + end end end From ae49ed2b1af0e798d7118205867dbe2397f5993b Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 02:43:56 -0600 Subject: [PATCH 090/132] api: fix legacy /post/index and /tag/index endpoints. Fixup for a1cd9d2b5. The route order matters here, the legacy endpoints need to go first. --- config/routes.rb | 12 +++++------ test/functional/legacy_controller_test.rb | 25 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 test/functional/legacy_controller_test.rb diff --git a/config/routes.rb b/config/routes.rb index a20f927f7..1ec90392d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -271,6 +271,12 @@ Rails.application.routes.draw do end end + # Legacy Danbooru 1 API endpoints + get "/tag/index.xml", :controller => "legacy", :action => "tags", :format => "xml" + get "/tag/index.json", :controller => "legacy", :action => "tags", :format => "json" + get "/post/index.xml", :controller => "legacy", :action => "posts", :format => "xml" + get "/post/index.json", :controller => "legacy", :action => "posts", :format => "json" + # Legacy Danbooru 1 redirects. get "/artist" => redirect {|params, req| "/artists?page=#{req.params[:page]}&search[name]=#{CGI.escape(req.params[:name].to_s)}"} get "/artist/show/:id" => redirect("/artists/%{id}") @@ -294,12 +300,6 @@ Rails.application.routes.draw do get "/wiki/show" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape(req.params[:title].to_s)}"} get "/help/:title" => redirect {|params, req| "/wiki_pages?title=#{CGI.escape('help:' + req.params[:title])}"} - # Legacy Danbooru 1 API endpoints - get "/tag/index.xml", :controller => "legacy", :action => "tags", :format => "xml" - get "/tag/index.json", :controller => "legacy", :action => "tags", :format => "json" - get "/post/index.xml", :controller => "legacy", :action => "posts", :format => "xml" - get "/post/index.json", :controller => "legacy", :action => "posts", :format => "json" - get "/login", to: "sessions#new", as: :login get "/logout", to: "sessions#sign_out", as: :logout get "/profile", to: "users#profile", as: :profile diff --git a/test/functional/legacy_controller_test.rb b/test/functional/legacy_controller_test.rb new file mode 100644 index 000000000..c4b591b70 --- /dev/null +++ b/test/functional/legacy_controller_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' + +class LegacyControllerTest < ActionDispatch::IntegrationTest + context "The legacy controller" do + context "post action" do + should "work" do + get "/post/index.xml" + assert_response :success + + get "/post/index.json" + assert_response :success + end + end + + context "tag action" do + should "work" do + get "/tag/index.xml" + assert_response :success + + get "/tag/index.json" + assert_response :success + end + end + end +end From fd182913829e65960df7c8a8633e25950914137c Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 25 Dec 2020 05:46:04 -0600 Subject: [PATCH 091/132] Add Danbooru Winter Sale. --- app/javascript/src/javascripts/common.js | 7 +++++++ app/models/user.rb | 4 +++- app/models/user_upgrade.rb | 6 +++++- app/views/layouts/default.html.erb | 19 +++++++++++++++++++ app/views/user_upgrades/new.html.erb | 10 ++++++++++ config/danbooru_default_config.rb | 6 ++++++ public/images/ablobgift.gif | Bin 0 -> 210166 bytes public/images/blobgift.png | Bin 0 -> 15830 bytes public/images/kemogift.png | Bin 0 -> 22398 bytes public/images/padoru.gif | Bin 0 -> 22445 bytes public/images/provgift.png | Bin 0 -> 16795 bytes 11 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 public/images/ablobgift.gif create mode 100644 public/images/blobgift.png create mode 100644 public/images/kemogift.png create mode 100644 public/images/padoru.gif create mode 100644 public/images/provgift.png diff --git a/app/javascript/src/javascripts/common.js b/app/javascript/src/javascripts/common.js index 04e2af233..94b6bb8d0 100644 --- a/app/javascript/src/javascripts/common.js +++ b/app/javascript/src/javascripts/common.js @@ -7,6 +7,13 @@ $(function() { e.preventDefault(); }); + $("#hide-promotion-notice").on("click.danbooru", function(e) { + $("#promotion-notice").hide(); + Cookie.put("hide_promotion_notice", "1", 1); + Cookie.put("hide_upgrade_account_notice", "1", 1); + e.preventDefault(); + }); + $("#hide-dmail-notice").on("click.danbooru", function(e) { var $dmail_notice = $("#dmail-notice"); $dmail_notice.hide(); diff --git a/app/models/user.rb b/app/models/user.rb index 01730294c..732bf3079 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -404,7 +404,9 @@ class User < ApplicationRecord end def tag_query_limit - if is_platinum? + if is_member? && Danbooru.config.is_promotion? + 1_000_000 + elsif is_platinum? Danbooru.config.base_tag_query_limit * 2 elsif is_gold? Danbooru.config.base_tag_query_limit diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 55c388a46..7ccd87987 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -34,7 +34,11 @@ class UserUpgrade < ApplicationRecord end def self.gold_price - 2000 + if Danbooru.config.is_promotion? + 1500 + else + 2000 + end end def self.platinum_price diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index c79fa8acb..778a3c143 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -70,6 +70,25 @@
    <%= render "users/verification_notice" %> + <% if Danbooru.config.is_promotion? && cookies[:hide_promotion_notice].blank? %> +
    + <% if rand <= 0.0025 %> + <%= tag.img src: "/images/provgift.png", width: 24, height: 24 %> + <% elsif rand <= 0.0025 %> + <%= tag.img src: "/images/kemogift.png", width: 24, height: 24 %> + <% elsif rand <= 0.0025 %> + <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> + <% elsif rand <= 0.0025 %> + <%= tag.img src: "/images/ablobgift.gif", width: 24, height: 24 %> + <% else %> + <%= tag.img src: "/images/blobgift.png", width: 24, height: 24 %> + <% end %> + + <%= link_to "Danbooru Winter Sale!", forum_topic_path(17832) %> 25% off Gold and unlimited searches for Members! + (<%= link_to "hide", "#", id: "hide-promotion-notice" %>) +
    + <% end %> + <% if !CurrentUser.is_anonymous? && !CurrentUser.is_gold? && cookies[:hide_upgrade_account_notice].blank? && params[:action] != "upgrade_information" %> <%= render "users/upgrade_notice" %> <% end %> diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index b5d278fd9..6f125ebed 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -18,6 +18,10 @@ <% else %>

    Upgrade Account

    + <% if Danbooru.config.is_promotion? %> +

    Danbooru Winter Sale! Gold and Platinum upgrades are 25% off from now until January 1st.

    + <% end %> +

    Upgrading your account gives you exclusive benefits and helps support <%= Danbooru.config.canonical_app_name %>. Your support helps keep the site ad-free for everyone!

    @@ -44,10 +48,16 @@ Free + <% if Danbooru.config.is_promotion? %> + $20 + <% end %> <%= cents_to_usd(UserUpgrade.gold_price) %>
    One time fee
    + <% if Danbooru.config.is_promotion? %> + $40 + <% end %> <%= cents_to_usd(UserUpgrade.platinum_price) %>
    One time fee
    diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 75622de73..08f6c29ee 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -494,6 +494,12 @@ module Danbooru def redis_url "redis://localhost:6379" end + + def is_promotion? + Time.use_zone("UTC") do + Time.zone.now < Time.zone.parse("2021-01-01") + end + end end EnvironmentConfiguration = Struct.new(:config) do diff --git a/public/images/ablobgift.gif b/public/images/ablobgift.gif new file mode 100644 index 0000000000000000000000000000000000000000..bca83dc11e986862b0c2ba60c6efeb9b81644b1b GIT binary patch literal 210166 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‚_#$=Q}^ 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 N%;?nwG`X6<8UW_fjlci^ literal 0 HcmV?d00001 diff --git a/public/images/blobgift.png b/public/images/blobgift.png new file mode 100644 index 0000000000000000000000000000000000000000..bfcb3aa64dea7e0688ae899c487813c737486e66 GIT binary patch literal 15830 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 literal 0 HcmV?d00001 diff --git a/public/images/kemogift.png b/public/images/kemogift.png new file mode 100644 index 0000000000000000000000000000000000000000..300da66fb4e6961231b56b5a87d1ea307561b266 GIT binary patch literal 22398 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 KKbLh*2~7Yb;jfec literal 0 HcmV?d00001 diff --git a/public/images/padoru.gif b/public/images/padoru.gif new file mode 100644 index 0000000000000000000000000000000000000000..454968b169d5a200e2eaa7903d2640f9fa1439f5 GIT binary patch literal 22445 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 F)&NxqEaktG3V{x@;S1{ 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 TI(LMDfq}u()z4*}Q$iB}78J?! literal 0 HcmV?d00001 From 57e70e5b827e34f22bf65caa0282d85f49a45153 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 26 Dec 2020 11:16:19 -0600 Subject: [PATCH 092/132] /users: fix exception when last_ip_addr is nil. --- app/views/users/index.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index fde5ef637..18dfbc6c2 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -17,7 +17,9 @@ <% end %> <% if policy(IpAddress).show? %> <% t.column "IP" do |user| %> - <%= link_to user.last_ip_addr, ip_address_path(user.last_ip_addr) %> + <% if user.last_ip_addr.present? %> + <%= link_to user.last_ip_addr, ip_address_path(user.last_ip_addr) %> + <% end %> <% end %> <% end %> <% t.column "Posts" do |user| %> From ddd149e22b931a185de05cebbaa60429cacaea5a Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 04:41:49 -0600 Subject: [PATCH 093/132] seo: mark login links as nofollow. Mark links to the login page as rel="nofollow" so that search crawlers don't constantly try to crawl it. Otherwise the fact the login url is different on every page (/login?url=) confuses crawlers. Also strip the url param from the canonical url () on the login page. --- app/views/layouts/_main_links.html.erb | 2 +- app/views/sessions/new.html.erb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/_main_links.html.erb b/app/views/layouts/_main_links.html.erb index 75d515e36..6ec3071b5 100644 --- a/app/views/layouts/_main_links.html.erb +++ b/app/views/layouts/_main_links.html.erb @@ -1,6 +1,6 @@ <% if CurrentUser.is_anonymous? %> - <%= nav_link_to("Login", login_path(url: request.fullpath)) %> + <%= nav_link_to("Login", login_path(url: request.fullpath), rel: "nofollow") %> <% else %> <%= nav_link_to("My Account #{unread_dmail_indicator(CurrentUser.user)}", profile_path) %> <% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 7ce1ffa31..c9f748da0 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,5 +1,6 @@ <% page_title "Login" %> <% meta_description "Login to #{Danbooru.config.app_name}" %> +<% canonical_url login_url %> <%= render "sessions/secondary_links" %> From 4756141156e3c4525fff0a80568b788aadffc378 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 04:45:46 -0600 Subject: [PATCH 094/132] emails: add script to delete invalid emails. We used to not validate user email addresses, which means we have a lot of users with invalid emails. This script deletes all emails that are missing both an `@` and a `.` This amounts to about 3000 invalid emails. There are an additional ~1000 emails that are missing just the `@` sign. Many of these are simple typos, for example skipping the `@` or typing a 2 instead. Some of these may be manually fixable. This fixes an issue where upgrading to Gold could fail if you had an invalid email address, because we prefilled the buyer's email address on the Stripe checkout page and an invalid email would cause Stripe to throw an error. --- script/fixes/067_delete_invalid_email_addresses.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 script/fixes/067_delete_invalid_email_addresses.rb diff --git a/script/fixes/067_delete_invalid_email_addresses.rb b/script/fixes/067_delete_invalid_email_addresses.rb new file mode 100755 index 000000000..00b96902a --- /dev/null +++ b/script/fixes/067_delete_invalid_email_addresses.rb @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +EmailAddress.transaction do + EmailAddress.where("address !~ ? AND address !~ ?", "@", "\\.").count +end From 7f1b798b05ffb57adbe10d9766bda2b0d1bc808d Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 05:26:21 -0600 Subject: [PATCH 095/132] searchable: refactor search_boolean_attribute. --- app/logical/concerns/searchable.rb | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index a83a9c83a..b1f9610b6 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -102,16 +102,11 @@ module Searchable where_operator(qualified_column, *range) end - def search_boolean_attribute(attribute, params) - return all unless params.key?(attribute) - - value = params[attribute].to_s - if value.truthy? - where(attribute => true) - elsif value.falsy? - where(attribute => false) + def search_boolean_attribute(attr, params) + if params[attr].present? + boolean_attribute_matches(attr, params[attr]) else - raise ArgumentError, "value must be truthy or falsy" + all end end @@ -133,6 +128,18 @@ module Searchable where_operator(qualified_column, *range) end + def boolean_attribute_matches(attribute, value) + value = value.to_s + + if value.truthy? + where(attribute => true) + elsif value.falsy? + where(attribute => false) + else + raise ArgumentError, "value must be truthy or falsy" + end + end + def text_attribute_matches(attribute, value, index_column: nil, ts_config: "english") return all unless value.present? From 1047b1f8af1861fa13de451318caf9bfa9f6f491 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 17:14:55 -0600 Subject: [PATCH 096/132] Fix #4427: Opening a post from a profile's favorites brings up a fav: search. --- app/views/users/_post_summary.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/_post_summary.html.erb b/app/views/users/_post_summary.html.erb index 208a237ca..b8987744e 100644 --- a/app/views/users/_post_summary.html.erb +++ b/app/views/users/_post_summary.html.erb @@ -18,7 +18,7 @@
    <% presenter.favorites.each do |post| %> - <%= PostPresenter.preview(post, :tags => "fav:#{user.name}") %> + <%= PostPresenter.preview(post, tags: "ordfav:#{user.name}") %> <% end %>
    From 7e8f859b24d381f00058afd687c07cf31c653f6c Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 21:03:26 -0600 Subject: [PATCH 097/132] tags: eliminate Tag.category_for method. Tag.category_for looked up a tag's category in the Redis cache. This was only used in a few places (in related tags, and on the popular/missed search pages). Get rid of this method so we can work towards getting rid of caching tag categories in Redis. --- app/logical/post_query_builder.rb | 5 +++++ app/logical/related_tag_query.rb | 7 +++++-- app/models/tag.rb | 12 ------------ app/views/explore/posts/missed_searches.html.erb | 8 ++++---- app/views/explore/posts/searches.html.erb | 6 +++--- test/unit/tag_test.rb | 10 ---------- 6 files changed, 17 insertions(+), 31 deletions(-) diff --git a/app/logical/post_query_builder.rb b/app/logical/post_query_builder.rb index 4046485f1..a694e7f75 100644 --- a/app/logical/post_query_builder.rb +++ b/app/logical/post_query_builder.rb @@ -985,6 +985,11 @@ class PostQueryBuilder def is_wildcard_search? is_single_tag? && tags.first.wildcard end + + def simple_tag + return nil if !is_simple_tag? + Tag.find_by_name(tags.first.name) + end end memoize :split_query, :normalized_query diff --git a/app/logical/related_tag_query.rb b/app/logical/related_tag_query.rb index 6a86b10b0..fb6c71413 100644 --- a/app/logical/related_tag_query.rb +++ b/app/logical/related_tag_query.rb @@ -71,9 +71,12 @@ class RelatedTagQuery end def other_wiki_pages - if Tag.category_for(query) == Tag.categories.copyright + tag = post_query.simple_tag + return [] if tag.nil? + + if tag.copyright? copyright_other_wiki_pages - elsif Tag.category_for(query) == Tag.categories.general + elsif tag.general? general_other_wiki_pages else [] diff --git a/app/models/tag.rb b/app/models/tag.rb index 527f8a797..c83d4e9b1 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -127,18 +127,6 @@ class Tag < ApplicationRecord Tag.where(name: tag_name).pick(:category).to_i end - def category_for(tag_name, options = {}) - return Tag.categories.general if tag_name.blank? - - if options[:disable_caching] - select_category_for(tag_name) - else - Cache.get("tc:#{Cache.hash(tag_name)}") do - select_category_for(tag_name) - end - end - end - def categories_for(tag_names, options = {}) if options[:disable_caching] Array(tag_names).inject({}) do |hash, tag_name| diff --git a/app/views/explore/posts/missed_searches.html.erb b/app/views/explore/posts/missed_searches.html.erb index 329b66c43..1bad6c802 100644 --- a/app/views/explore/posts/missed_searches.html.erb +++ b/app/views/explore/posts/missed_searches.html.erb @@ -15,11 +15,11 @@ - <% @missed_searches.each do |tags, count| %> - - <%= link_to tags, posts_path(:tags => tags) %> + <% @missed_searches.each do |search, count| %> + + <%= link_to search, posts_path(tags: search) %> - <% unless WikiPage.titled(tags).exists? %> + <% unless WikiPage.titled(search).exists? %> N <% end %> diff --git a/app/views/explore/posts/searches.html.erb b/app/views/explore/posts/searches.html.erb index 5256427eb..a00154e61 100644 --- a/app/views/explore/posts/searches.html.erb +++ b/app/views/explore/posts/searches.html.erb @@ -13,9 +13,9 @@ - <% @searches.each do |tags, count| %> - - <%= link_to tags, posts_path(:tags => tags) %> + <% @searches.each do |search, count| %> + + <%= link_to search, posts_path(tags: search) %> <%= count.to_i %> <% end %> diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index 0c4bd3dce..a638dc9a9 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -13,16 +13,6 @@ class TagTest < ActiveSupport::TestCase end context "A tag category fetcher" do - should "fetch for a single tag" do - FactoryBot.create(:artist_tag, :name => "test") - assert_equal(Tag.categories.artist, Tag.category_for("test")) - end - - should "fetch for a single tag with strange markup" do - FactoryBot.create(:artist_tag, :name => "!@$%") - assert_equal(Tag.categories.artist, Tag.category_for("!@$%")) - end - should "fetch for multiple tags" do FactoryBot.create(:artist_tag, :name => "aaa") FactoryBot.create(:copyright_tag, :name => "bbb") From 9dc788c0cec263a3000d3cd44eb0e8bb49163f40 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 22:47:52 -0600 Subject: [PATCH 098/132] users: improve sockpuppet detection on signup. Require new accounts to verify their email address if any of the following conditions are true: * Their IP is a proxy. * Their IP is under a partial IP ban. * They're creating a new account while logged in to another account. * Somebody recently created an account from the same IP in the last week. Changes from before: * Allow logged in users to view the signup page and create new accounts. Creating a new account while logged in to your old account is now allowed, but it requires email verification. This is a honeypot. * Creating multiple accounts from the same IP is now allowed, but they require email verification. Previously the same IP check was only for the last day (now it's the last week), and only for an exact IP match (now it's a subnet match, /24 for IPv4 or /64 for IPv6). * New account verification is disabled for private IPs (e.g. 127.0.0.1, 192.168.0.1), to make development or running personal boorus easier (fixes #4618). --- app/controllers/users_controller.rb | 2 +- app/logical/danbooru/http.rb | 1 + app/logical/user_verifier.rb | 46 +++++++++++ app/policies/user_policy.rb | 10 +-- test/functional/users_controller_test.rb | 98 +++++++++++++++--------- 5 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 app/logical/user_verifier.rb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index eb0e2f7b4..f28cd9390 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -59,7 +59,7 @@ class UsersController < ApplicationController end def create - requires_verification = IpLookup.new(CurrentUser.ip_addr).is_proxy? || IpBan.hit!(:partial, CurrentUser.ip_addr) + requires_verification = UserVerifier.new(CurrentUser.user, request).requires_verification? @user = authorize User.new( last_ip_addr: CurrentUser.ip_addr, diff --git a/app/logical/danbooru/http.rb b/app/logical/danbooru/http.rb index e1b76b16b..bbf30cafd 100644 --- a/app/logical/danbooru/http.rb +++ b/app/logical/danbooru/http.rb @@ -1,3 +1,4 @@ +require "danbooru/http/application_client" require "danbooru/http/html_adapter" require "danbooru/http/xml_adapter" require "danbooru/http/cache" diff --git a/app/logical/user_verifier.rb b/app/logical/user_verifier.rb new file mode 100644 index 000000000..e225924ae --- /dev/null +++ b/app/logical/user_verifier.rb @@ -0,0 +1,46 @@ +# Checks whether a new account seems suspicious and should require email verification. + +class UserVerifier + attr_reader :current_user, :request + + # current_user is the user creating the new account, not the new account itself. + def initialize(current_user, request) + @current_user, @request = current_user, request + end + + def requires_verification? + return false if is_local_ip? + + # we check for IP bans first to make sure we bump the IP ban hit count + is_ip_banned? || is_logged_in? || is_recent_signup? || is_proxy? + end + + private + + def ip_address + @ip_address ||= IPAddress.parse(request.remote_ip) + end + + def is_local_ip? + ip_address.loopback? || ip_address.link_local? || ip_address.private? || ip_address.try(:unique_local?) + end + + def is_logged_in? + !current_user.is_anonymous? + end + + def is_recent_signup?(age: 24.hours) + subnet_len = ip_address.ipv4? ? 24 : 64 + subnet = "#{ip_address}/#{subnet_len}" + + User.where("last_ip_addr <<= ?", subnet).where("created_at > ?", age.ago).exists? + end + + def is_ip_banned? + IpBan.hit!(:partial, ip_address.to_s) + end + + def is_proxy? + IpLookup.new(ip_address).is_proxy? + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index fa1dff0f4..1aaaaa84f 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -1,6 +1,10 @@ class UserPolicy < ApplicationPolicy def create? - user.is_anonymous? && !sockpuppet? + true + end + + def new? + true end def update? @@ -31,10 +35,6 @@ class UserPolicy < ApplicationPolicy user.is_admin? || record.id == user.id || !record.enable_private_favorites? end - def sockpuppet? - User.where(last_ip_addr: request.remote_ip).where("created_at > ?", 1.day.ago).exists? - end - def permitted_attributes_for_create [:name, :password, :password_confirmation, { email_address_attributes: [:address] }] end diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index fe4353683..38216a1a6 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -243,6 +243,11 @@ class UsersControllerTest < ActionDispatch::IntegrationTest get new_user_path assert_response :success end + + should "render for a logged in user" do + get_auth new_user_path, @user + assert_response :success + end end context "create action" do @@ -256,11 +261,6 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_no_enqueued_emails end - should "not allow logged in users to create a new account" do - post_auth users_path, @user, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} - assert_response 403 - end - should "create a user with a valid email" do post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1", email: "webmaster@danbooru.donmai.us" }} @@ -289,45 +289,71 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end end - should "mark users signing up from proxies as requiring verification" do - skip unless IpLookup.enabled? + context "sockpuppet detection" do + setup do + @private_ip = "192.168.0.1" + @valid_ip = "187.37.226.17" # a random valid, non-proxy public IP + @proxy_ip = "51.15.128.1" + end - self.remote_addr = "51.15.128.1" - post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + should "mark accounts created by already logged in users as requiring verification" do + self.remote_addr = @valid_ip - assert_redirected_to User.last - assert_equal(true, User.last.requires_verification) - end + post_auth users_path, @user, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} - should "mark users signing up from a partial banned IP as requiring verification" do - skip unless IpLookup.enabled? - self.remote_addr = "187.37.226.17" + assert_redirected_to User.last + assert_equal(true, User.last.requires_verification) + end - @ip_ban = create(:ip_ban, ip_addr: self.remote_addr, category: :partial) - post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + should "mark users signing up from proxies as requiring verification" do + skip unless IpLookup.enabled? + self.remote_addr = @proxy_ip - assert_redirected_to User.last - assert_equal(true, User.last.requires_verification) - assert_equal(1, @ip_ban.reload.hit_count) - assert(@ip_ban.last_hit_at > 1.minute.ago) - end + post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} - should "not mark users signing up from non-proxies as requiring verification" do - skip unless IpLookup.enabled? - self.remote_addr = "187.37.226.17" - post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + assert_redirected_to User.last + assert_equal(true, User.last.requires_verification) + end - assert_redirected_to User.last - assert_equal(false, User.last.requires_verification) - end + should "mark users signing up from a partial banned IP as requiring verification" do + self.remote_addr = @valid_ip - context "with sockpuppet validation enabled" do - should "not allow registering multiple accounts with the same IP" do - assert_difference("User.count", 0) do - @user.update(last_ip_addr: "127.0.0.1") - post users_path, params: {:user => {:name => "dupe", :password => "xxxxx1", :password_confirmation => "xxxxx1"}} - assert_response 403 - end + @ip_ban = create(:ip_ban, ip_addr: self.remote_addr, category: :partial) + post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + + assert_redirected_to User.last + assert_equal(true, User.last.requires_verification) + assert_equal(1, @ip_ban.reload.hit_count) + assert(@ip_ban.last_hit_at > 1.minute.ago) + end + + should "not mark users signing up from non-proxies as requiring verification" do + skip unless IpLookup.enabled? + self.remote_addr = @valid_ip + + post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + + assert_redirected_to User.last + assert_equal(false, User.last.requires_verification) + end + + should "mark accounts registered from an IPv4 address recently used for another account as requiring verification" do + @user.update!(last_ip_addr: @valid_ip) + self.remote_addr = @valid_ip + + post users_path, params: { user: { name: "dupe", password: "xxxxx1", password_confirmation: "xxxxx1" }} + + assert_redirected_to User.last + assert_equal(true, User.last.requires_verification) + end + + should "not mark users signing up from localhost as requiring verification" do + self.remote_addr = "127.0.0.1" + + post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + + assert_redirected_to User.last + assert_equal(false, User.last.requires_verification) end end end From 805bbc8a33712a6237252f3ac4744a5e8a38eaf7 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 27 Dec 2020 23:55:25 -0600 Subject: [PATCH 099/132] users: add config option to disable verification of new accounts. Fixes #4618. --- app/logical/user_verifier.rb | 1 + config/danbooru_default_config.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/logical/user_verifier.rb b/app/logical/user_verifier.rb index e225924ae..df0d43c93 100644 --- a/app/logical/user_verifier.rb +++ b/app/logical/user_verifier.rb @@ -9,6 +9,7 @@ class UserVerifier end def requires_verification? + return false if !Danbooru.config.new_user_verification? return false if is_local_ip? # we check for IP bans first to make sure we bump the IP ban hit count diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 08f6c29ee..947dc75c1 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -69,6 +69,19 @@ module Danbooru "#{source_code_url}/issues" end + # If true, new accounts will require email verification if they seem + # suspicious (they were created using a proxy, multiple accounts were + # created by the same IP, etc). + # + # This doesn't apply to personal or development installs running on + # localhost or the local network. + # + # Disable this if you're running a public booru and you don't want email + # verification for new accounts. + def new_user_verification? + true + end + # An array of regexes containing disallowed usernames. def user_name_blacklist [] From 59c61f249f7df000b3c6b910065adbcb56484f9f Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 00:57:18 -0600 Subject: [PATCH 100/132] posts helper: remove dupe nav_params_for method. Already defined in PaginationHelper. --- app/helpers/posts_helper.rb | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index dc5b4d0c3..378ad5a71 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -58,11 +58,4 @@ module PostsHelper return false if !params.key?(:pool_id) return params[:pool_id].to_i == pool.id end - - private - - def nav_params_for(page) - query_params = params.except(:controller, :action, :id).merge(page: page).permit! - { params: query_params } - end end From 6a52216631f6461e59c29c45fc7ef24e61dbf058 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 01:19:09 -0600 Subject: [PATCH 101/132] newrelic: log additional request headers. Log the Referer header, as well as the Sec-Fetch-* headers. These are only sent by recent versions of Chrome; see https://www.w3.org/TR/fetch-metadata. --- app/logical/danbooru_logger.rb | 41 +++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/app/logical/danbooru_logger.rb b/app/logical/danbooru_logger.rb index 09f763e7e..63f8c2179 100644 --- a/app/logical/danbooru_logger.rb +++ b/app/logical/danbooru_logger.rb @@ -1,4 +1,6 @@ class DanbooruLogger + HEADERS = %w[referer sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user] + def self.info(message, params = {}) Rails.logger.info(message) @@ -22,9 +24,29 @@ class DanbooruLogger end def self.add_session_attributes(request, session, user) - request_params = request.parameters.with_indifferent_access.except(:controller, :action) - session_params = session.to_h.with_indifferent_access.slice(:session_id, :started_at) - user_params = { + add_attributes("request.headers", header_params(request)) + add_attributes("request.params", request_params(request)) + add_attributes("session.params", session_params(session)) + add_attributes("user", user_params(request, user)) + end + + def self.header_params(request) + headers = request.headers.to_h.select { |header, value| header.match?(/\AHTTP_/) } + headers = headers.transform_keys { |header| header.delete_prefix("HTTP_").downcase } + headers = headers.select { |header, value| header.in?(HEADERS) } + headers + end + + def self.request_params(request) + request.parameters.with_indifferent_access.except(:controller, :action) + end + + def self.session_params(session) + session.to_h.with_indifferent_access.slice(:session_id, :started_at) + end + + def self.user_params(request, user) + { id: user&.id, name: user&.name, level: user&.level_string, @@ -32,22 +54,21 @@ class DanbooruLogger country: request.headers["CF-IPCountry"], safe_mode: CurrentUser.safe_mode? } - - add_attributes("request.params", request_params) - add_attributes("session.params", session_params) - add_attributes("user", user_params) end def self.add_attributes(prefix, hash) - return unless defined?(::NewRelic) - attributes = flatten_hash(hash).transform_keys { |key| "#{prefix}.#{key}" } attributes.delete_if { |key, value| key.end_with?(*Rails.application.config.filter_parameters.map(&:to_s)) } - ::NewRelic::Agent.add_custom_attributes(attributes) + add_custom_attributes(attributes) end private_class_method + def self.add_custom_attributes(attributes) + return unless defined?(::NewRelic) + ::NewRelic::Agent.add_custom_attributes(attributes) + end + # flatten_hash({ foo: { bar: { baz: 42 } } }) # => { "foo.bar.baz" => 42 } def self.flatten_hash(hash) From 0b6fca7ff81fef224b6d31a75d1b46beb8a68f21 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 16:30:50 -0600 Subject: [PATCH 102/132] Update ruby gems and yarn packages. --- Gemfile.lock | 27 +++-- yarn.lock | 324 ++++++++++++++++++++++----------------------------- 2 files changed, 155 insertions(+), 196 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 021635222..1fd362297 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,7 +94,7 @@ GEM ansi (1.5.0) ast (2.4.1) aws-eventstream (1.1.0) - aws-partitions (1.409.0) + aws-partitions (1.413.0) aws-sdk-core (3.110.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) @@ -137,9 +137,8 @@ GEM xpath (~> 3.2) childprocess (3.0.0) chronic (0.10.2) - codecov (0.2.12) - json - simplecov + codecov (0.2.15) + simplecov (>= 0.15, < 0.21) coderay (1.1.3) concurrent-ruby (1.1.7) crass (1.0.6) @@ -151,7 +150,7 @@ GEM activerecord (>= 3.0, < 6.2) delayed_job (>= 3.0, < 5) diff-lcs (1.4.4) - docile (1.3.2) + docile (1.3.4) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (2.7.6) @@ -161,11 +160,11 @@ GEM erubi (1.10.0) factory_bot (6.1.0) activesupport (>= 5.0.0) - faraday (1.1.0) + faraday (1.2.0) multipart-post (>= 1.2, < 3) ruby2_keywords ffaker (2.17.0) - ffi (1.13.1) + ffi (1.14.2) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -186,7 +185,7 @@ GEM concurrent-ruby (~> 1.0) ipaddress_2 (0.13.0) jmespath (1.4.0) - json (2.4.1) + json (2.5.1) jwt (2.2.2) kgio (2.11.3) listen (3.3.3) @@ -213,7 +212,7 @@ GEM builder minitest (>= 5.0) ruby-progressbar - mocha (1.11.2) + mocha (1.12.0) mock_redis (0.26.0) msgpack (1.3.3) multi_json (1.15.0) @@ -237,7 +236,7 @@ GEM multi_xml (~> 0.5) rack (>= 1.2, < 3) parallel (1.20.1) - parser (2.7.2.0) + parser (3.0.0.0) ast (~> 2.4.1) pg (1.2.3) pry (0.13.1) @@ -254,7 +253,7 @@ GEM pundit (2.1.0) activesupport (>= 3.0.0) rack (2.2.3) - rack-mini-profiler (2.2.0) + rack-mini-profiler (2.2.1) rack (>= 1.2.0) rack-proxy (0.6.5) rack @@ -288,7 +287,7 @@ GEM thor (~> 1.0) rainbow (3.0.0) raindrops (0.19.1) - rake (13.0.1) + rake (13.0.3) rakismet (1.5.4) rb-fsevent (0.10.4) rb-inotify (0.10.1) @@ -303,7 +302,7 @@ GEM actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.4) - rubocop (1.6.1) + rubocop (1.7.0) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) @@ -371,7 +370,7 @@ GEM unf_ext unf_ext (0.0.7.7) unicode-display_width (1.7.0) - unicorn (5.7.0) + unicorn (5.8.0) kgio (~> 2.6) raindrops (~> 0.7) unicorn-worker-killer (0.4.4) diff --git a/yarn.lock b/yarn.lock index bba9711e4..fd85d4cd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" @@ -35,12 +35,12 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.10": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.10.tgz#2b188fc329fb8e4f762181703beffc0fe6df3460" - integrity sha512-6mCdfhWgmqLdtTkhXjnIz0LcdVCd26wS2JXRtj2XY0u5klDsXBREA/pG5NVOuVnF2LUrBGNFtQkIqqTbblg0ww== +"@babel/generator@^7.12.10", "@babel/generator@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" + integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== dependencies: - "@babel/types" "^7.12.10" + "@babel/types" "^7.12.11" jsesc "^2.5.1" source-map "^0.5.0" @@ -59,23 +59,6 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.12.10": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.10.tgz#a58cb96a793dc0fcd5c9ed3bb36d62fdc60534c2" - integrity sha512-3Kcr2LGpL7CTRDTTYm1bzeor9qZbxbvU2AxsLA6mUG9gYarSfIKMK0UlU+azLWI+s0+BH768bwyaziWB2NOJlQ== - dependencies: - "@babel/helper-annotate-as-pure" "^7.12.10" - "@babel/helper-module-imports" "^7.12.5" - "@babel/types" "^7.12.10" - -"@babel/helper-builder-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" - integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-compilation-targets@^7.12.5": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" @@ -121,16 +104,16 @@ dependencies: "@babel/types" "^7.12.1" -"@babel/helper-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" - integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== +"@babel/helper-function-name@^7.10.4", "@babel/helper-function-name@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" + integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-get-function-arity" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/types" "^7.12.11" -"@babel/helper-get-function-arity@^7.10.4": +"@babel/helper-get-function-arity@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== @@ -144,7 +127,7 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-member-expression-to-functions@^7.12.1": +"@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== @@ -173,7 +156,7 @@ "@babel/types" "^7.12.1" lodash "^4.17.19" -"@babel/helper-optimise-call-expression@^7.10.4": +"@babel/helper-optimise-call-expression@^7.10.4", "@babel/helper-optimise-call-expression@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== @@ -195,14 +178,14 @@ "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.12.1": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.5.tgz#f009a17543bbbbce16b06206ae73b63d3fca68d9" - integrity sha512-5YILoed0ZyIpF4gKcpZitEnXEJ9UoDRki1Ey6xz46rxOzfNMAhVIJMoune1hmPVxh40LRv1+oafz7UsWX+vyWA== + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" + integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== dependencies: - "@babel/helper-member-expression-to-functions" "^7.12.1" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.12.5" - "@babel/types" "^7.12.5" + "@babel/helper-member-expression-to-functions" "^7.12.7" + "@babel/helper-optimise-call-expression" "^7.12.10" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.11" "@babel/helper-simple-access@^7.12.1": version "7.12.1" @@ -218,22 +201,22 @@ dependencies: "@babel/types" "^7.12.1" -"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": - version "7.11.0" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" - integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" + integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.11" -"@babel/helper-validator-identifier@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" - integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== -"@babel/helper-validator-option@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" - integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" + integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== "@babel/helper-wrap-function@^7.10.4": version "7.12.3" @@ -263,15 +246,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.12.10", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81" - integrity sha512-PJdRPwyoOqFAWfLytxrWwGrAxghCgh/yTNCYciOz8QgjflA7aZhECPZAa2VUedKg2+QMWkI0L9lynh2SNmNEgA== +"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" + integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== "@babel/plugin-proposal-async-generator-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" - integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" + integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-remap-async-to-generator" "^7.12.1" @@ -286,9 +269,9 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-proposal-decorators@^7.10.5": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.12.1.tgz#59271439fed4145456c41067450543aee332d15f" - integrity sha512-knNIuusychgYN8fGJHONL0RbFxLGawhXOJNLBk75TniTsZZeA+wdkDuv6wp4lGwzQEKjZi6/WYtnb3udNPmQmQ== + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.12.12.tgz#067a6d3d6ca86d54cf56bb183239199c20daeafe" + integrity sha512-fhkE9lJYpw2mjHelBpM2zCbaA11aov2GJs7q4cFaXNrWx0H3bW58H9Esy2rdtYOghFBEYUDRIpvlgi+ZD+AvvQ== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" @@ -505,10 +488,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" - integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== +"@babel/plugin-transform-block-scoping@^7.12.11": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" + integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -667,14 +650,15 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-jsx@^7.10.4": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.10.tgz#a7af3097c73479123594c8c8fe39545abebd44e3" - integrity sha512-MM7/BC8QdHXM7Qc1wdnuk73R4gbuOpfrSUgfV/nODGc86sPY1tgmY2M9E9uAnf2e4DOIp8aKGWqgZfQxnTNGuw== + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.12.tgz#b0da51ffe5f34b9a900e9f1f5fb814f9e512d25e" + integrity sha512-JDWGuzGNWscYcq8oJVCtSE61a5+XAOos+V0HrxnDieUus4UMnBEosDnY1VJqU5iZ4pA04QY7l0+JvHL1hZEfsw== dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.10" + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" + "@babel/types" "^7.12.12" "@babel/plugin-transform-regenerator@^7.10.1", "@babel/plugin-transform-regenerator@^7.12.1": version "7.12.1" @@ -751,15 +735,15 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/preset-env@^7.11.0": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.10.tgz#ca981b95f641f2610531bd71948656306905e6ab" - integrity sha512-Gz9hnBT/tGeTE2DBNDkD7BiWRELZt+8lSysHuDwmYXUIvtwZl0zI+D6mZgXZX0u8YBlLS4tmai9ONNY9tjRgRA== + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" + integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== dependencies: "@babel/compat-data" "^7.12.7" "@babel/helper-compilation-targets" "^7.12.5" "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-validator-option" "^7.12.1" + "@babel/helper-validator-option" "^7.12.11" "@babel/plugin-proposal-async-generator-functions" "^7.12.1" "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-dynamic-import" "^7.12.1" @@ -788,7 +772,7 @@ "@babel/plugin-transform-arrow-functions" "^7.12.1" "@babel/plugin-transform-async-to-generator" "^7.12.1" "@babel/plugin-transform-block-scoped-functions" "^7.12.1" - "@babel/plugin-transform-block-scoping" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.11" "@babel/plugin-transform-classes" "^7.12.1" "@babel/plugin-transform-computed-properties" "^7.12.1" "@babel/plugin-transform-destructuring" "^7.12.1" @@ -818,7 +802,7 @@ "@babel/plugin-transform-unicode-escapes" "^7.12.1" "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.12.10" + "@babel/types" "^7.12.11" core-js-compat "^3.8.0" semver "^5.5.0" @@ -858,26 +842,26 @@ "@babel/types" "^7.12.7" "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.10.tgz#2d1f4041e8bf42ea099e5b2dc48d6a594c00017a" - integrity sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg== + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" + integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.10" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.10" - "@babel/types" "^7.12.10" + "@babel/code-frame" "^7.12.11" + "@babel/generator" "^7.12.11" + "@babel/helper-function-name" "^7.12.11" + "@babel/helper-split-export-declaration" "^7.12.11" + "@babel/parser" "^7.12.11" + "@babel/types" "^7.12.12" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.4.4", "@babel/types@^7.7.0": - version "7.12.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260" - integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.4.4", "@babel/types@^7.7.0": + version "7.12.12" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" + integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== dependencies: - "@babel/helper-validator-identifier" "^7.10.4" + "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" @@ -907,25 +891,25 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.1.tgz#ccfef6ddbe59f8fe8f694783e1d3eb88902dc5eb" integrity sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ== -"@nodelib/fs.scandir@2.1.3": - version "2.1.3" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" - integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw== +"@nodelib/fs.scandir@2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" + integrity sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA== dependencies: - "@nodelib/fs.stat" "2.0.3" + "@nodelib/fs.stat" "2.0.4" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3" - integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA== +"@nodelib/fs.stat@2.0.4", "@nodelib/fs.stat@^2.0.2": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz#a3f2dd61bab43b8db8fa108a121cfffe4c676655" + integrity sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q== "@nodelib/fs.walk@^1.2.3": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976" - integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ== + version "1.2.6" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz#cce9396b30aa5afe9e3756608f5831adcb53d063" + integrity sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow== dependencies: - "@nodelib/fs.scandir" "2.1.3" + "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" "@npmcli/move-file@^1.0.1": @@ -936,9 +920,9 @@ mkdirp "^1.0.4" "@popperjs/core@^2.4.4": - version "2.5.4" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a" - integrity sha512-ZpKr+WTb8zsajqgDkvCEWgp6d5eJT6Q63Ng2neTbzBO76Lbe91vX/iVIW9dikq+Fs3yEo+ls4cxeXABD2LtcbQ== + version "2.6.0" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.6.0.tgz#f022195afdfc942e088ee2101285a1d31c7d727f" + integrity sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw== "@rails/ujs@^6.0.2-1": version "6.1.0" @@ -1069,9 +1053,9 @@ integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== "@types/node@*": - version "14.14.13" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.13.tgz#9e425079799322113ae8477297ae6ef51b8e0cdf" - integrity sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ== + version "14.14.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.16.tgz#3cc351f8d48101deadfed4c9e4f116048d437b4b" + integrity sha512-naXYePhweTi+BMv11TgioE2/FXU4fSl29HAH1ffxVciNsH3rYXjNP2yM8wqmSm7jS20gM8TIklKiTen+1iVncw== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -1508,11 +1492,6 @@ assign-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= -astral-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" - integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== - astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -2061,9 +2040,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: - version "1.0.30001166" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001166.tgz#ca73e8747acfd16a4fd6c4b784f1b995f9698cf8" - integrity sha512-nCL4LzYK7F4mL0TjEMeYavafOGnBa98vTudH5c8lW9izUjnB99InG6pmC1ElAI1p0GlyZajv4ltUdFXvOHIl1A== + version "1.0.30001170" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001170.tgz#0088bfecc6a14694969e391cc29d7eb6362ca6a7" + integrity sha512-Dd4d/+0tsK0UNLrZs3CvNukqalnVTRrxb5mcQm8rHL49t7V5ZaTygwXkrq+FB+dVDf++4ri8eJnFEJAB8332PA== case-sensitive-paths-webpack-plugin@^2.3.0: version "2.3.0" @@ -3110,9 +3089,9 @@ ee-first@1.1.1: integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= electron-to-chromium@^1.3.621: - version "1.3.625" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.625.tgz#a7bd18da4dc732c180b2e95e0e296c0bf22f3bd6" - integrity sha512-CsLk/r0C9dAzVPa9QF74HIXduxaucsaRfqiOYvIv2PRhvyC6EOqc/KbpgToQuDVgPf3sNAFZi3iBu4vpGOwGag== + version "1.3.633" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.633.tgz#16dd5aec9de03894e8d14a1db4cda8a369b9b7fe" + integrity sha512-bsVCsONiVX1abkWdH7KtpuDAhsQ3N3bjPYhROSAXE78roJKet0Y5wznA14JE9pzbwSZmSMAW6KiKYf1RvbTJkA== elliptic@^6.5.3: version "6.5.3" @@ -3181,9 +3160,9 @@ entities@^2.0.0: integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== errno@^0.1.3, errno@~0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" - integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== + version "0.1.8" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== dependencies: prr "~1.0.1" @@ -3322,9 +3301,9 @@ eslint-visitor-keys@^2.0.0: integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@^7.0.0: - version "7.15.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.15.0.tgz#eb155fb8ed0865fcf5d903f76be2e5b6cd7e0bc7" - integrity sha512-Vr64xFDT8w30wFll643e7cGrIkPEU50yIiI36OdSIDoSGguIeaLzBo0vpGvzo9RECUqq7htURfwEtKqwytkqzA== + version "7.16.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.16.0.tgz#a761605bf9a7b32d24bb7cde59aeb0fd76f06092" + integrity sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw== dependencies: "@babel/code-frame" "^7.0.0" "@eslint/eslintrc" "^0.2.2" @@ -3360,7 +3339,7 @@ eslint@^7.0.0: semver "^7.2.1" strip-ansi "^6.0.0" strip-json-comments "^3.1.0" - table "^5.2.3" + table "^6.0.4" text-table "^0.2.0" v8-compile-cache "^2.0.3" @@ -3605,9 +3584,9 @@ fastest-levenshtein@^1.0.12: integrity sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow== fastq@^1.6.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.9.0.tgz#e16a72f338eaca48e91b5c23593bcc2ef66b7947" - integrity sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w== + version "1.10.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.10.0.tgz#74dbefccade964932cdf500473ef302719c652bb" + integrity sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA== dependencies: reusify "^1.0.4" @@ -3936,9 +3915,9 @@ get-installed-path@4.0.8: global-modules "1.0.0" get-intrinsic@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.1.tgz#94a9768fcbdd0595a1c9273aacf4c89d075631be" - integrity sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg== + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" + integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== dependencies: function-bind "^1.1.1" has "^1.0.3" @@ -4306,9 +4285,9 @@ html-comment-regex@^1.1.0: integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== html-entities@^1.3.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.3.tgz#3dca638a43ee7de316fc23067398491152ad4736" - integrity sha512-/VulV3SYni1taM7a4RMdceqzJWR39gpZHjBwUnsCFKWV/GJkD14CJ5F7eWcZozmHJK0/f/H5U3b3SiPkuvxMgg== + version "1.4.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc" + integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA== html-tags@^3.1.0: version "3.1.0" @@ -4470,9 +4449,9 @@ import-fresh@^2.0.0: resolve-from "^3.0.0" import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: - version "3.2.2" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.2.tgz#fc129c160c5d68235507f4331a6baad186bdbc3e" - integrity sha512-cTPNrlvJT6twpYy+YmKUKrTSjWFs3bjYjAhCwm+z4EOCubZxAuO+hHpRN64TqjEaYSHs7tJAE0w1CKMGmsG/lw== + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== dependencies: parent-module "^1.0.0" resolve-from "^4.0.0" @@ -5362,9 +5341,9 @@ mdast-util-from-markdown@^0.8.0: unist-util-stringify-position "^2.0.0" mdast-util-to-markdown@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.1.tgz#0e07d3f871e056bffc38a0cf50c7298b56d9e0d6" - integrity sha512-4qJtZ0qdyYeexAXoOZiU0uHIFVncJAmCkHkSluAsvDaVWODtPyNEo9I1ns0T4ulxu2EHRH5u/bt1cV0pdHCX+A== + version "0.6.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.2.tgz#8fe6f42a2683c43c5609dfb40407c095409c85b4" + integrity sha512-iRczns6WMvu0hUw02LXsPDJshBIwtUPbvHBWo19IQeU0YqmzlA8Pd30U8V7uiI0VPkxzS7A/NXBXH6u+HS87Zg== dependencies: "@types/unist" "^2.0.0" longest-streak "^2.0.0" @@ -5426,9 +5405,9 @@ meow@^3.7.0: trim-newlines "^1.0.0" meow@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-8.0.0.tgz#1aa10ee61046719e334ffdc038bb5069250ec99a" - integrity sha512-nbsTRz2fwniJBFgUkcdISq8y/q9n9VbiHYbfwklFh5V4V2uAcxtKQkDc0yCLPM/kP0d+inZBewn3zJqewHE7kg== + version "8.1.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.0.tgz#0fcaa267e35e4d58584b8205923df6021ddcc7ba" + integrity sha512-fNWkgM1UVMey2kf24yLiccxLihc5W+6zVus3/N0b+VfnJgxV99E9u04X6NAiKdg6ED7DAQBX5sy36NM0QJZkWA== dependencies: "@types/minimist" "^1.2.0" camelcase-keys "^6.2.2" @@ -5528,9 +5507,9 @@ mime@1.6.0: integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== mime@^2.4.4: - version "2.4.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + version "2.4.7" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.7.tgz#962aed9be0ed19c91fd7dc2ece5d7f4e89a90d74" + integrity sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA== mimic-fn@^2.1.0: version "2.1.0" @@ -6026,9 +6005,9 @@ object-copy@^0.1.0: kind-of "^3.0.3" object-hash@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" - integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg== + version "2.1.1" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.1.1.tgz#9447d0279b4fcf80cff3259bf66a1dc73afabe09" + integrity sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ== object-inspect@^1.8.0: version "1.9.0" @@ -7091,9 +7070,9 @@ postcss-selector-matches@^4.0.0: postcss "^7.0.2" postcss-selector-not@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.0.tgz#c68ff7ba96527499e832724a2674d65603b645c0" - integrity sha512-W+bkBZRhqJaYN8XAnbbZPLWMvZD1wKTu0UxtFKdhtGjWYmxhkUneoeOhRJKdAE5V7ZTlnbHfCR+6bNwK9e1dTQ== + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.1.tgz#263016eef1cf219e0ade9a913780fc1f48204cbf" + integrity sha512-YolvBgInEK5/79C+bdFMyzqTg6pkYqDbzZIST/PDMqa/o3qtXenD05apBG2jLgT0/BQ77d4U2UK12jWpilqMAQ== dependencies: balanced-match "^1.0.0" postcss "^7.0.2" @@ -8046,15 +8025,6 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slice-ansi@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" - integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== - dependencies: - ansi-styles "^3.2.0" - astral-regex "^1.0.0" - is-fullwidth-code-point "^2.0.0" - slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" @@ -8621,17 +8591,7 @@ svgo@^1.0.0: unquote "~1.1.1" util.promisify "~1.0.0" -table@^5.2.3: - version "5.4.6" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" - integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== - dependencies: - ajv "^6.10.2" - lodash "^4.17.14" - slice-ansi "^2.1.0" - string-width "^3.0.0" - -table@^6.0.3: +table@^6.0.3, table@^6.0.4: version "6.0.4" resolved "https://registry.yarnpkg.com/table/-/table-6.0.4.tgz#c523dd182177e926c723eb20e1b341238188aa0d" integrity sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw== @@ -9295,9 +9255,9 @@ webpack-cli@^3.3.0, webpack-cli@^3.3.12: yargs "^13.3.2" webpack-dev-middleware@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz#0019c3db716e3fa5cecbf64f2ab88a74bab331f3" - integrity sha512-1xC42LxbYoqLNAhV6YzTYacicgMZQTqRd27Sim9wn5hJrX3I5nxYy1SxSd4+gjUFsz1dQFj+yEe6zEVmSkeJjw== + version "3.7.3" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" + integrity sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ== dependencies: memory-fs "^0.4.1" mime "^2.4.4" From a69ef8fa89f913b1c391e108391401ffce35f8bf Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 16:36:52 -0600 Subject: [PATCH 103/132] routes: add /user_upgrade/new redirect. Redirect the old user upgrade page, /user_upgrade/new, to the new user upgrade page, /user_upgrades/new page. Some old forum posts still link to the old page. --- config/routes.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 1ec90392d..cd1b3e587 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -314,7 +314,8 @@ Rails.application.routes.draw do get "/static/site_map" => "static#site_map", :as => "site_map" get "/static/contact" => "static#contact", :as => "contact" get "/static/dtext_help" => "static#dtext_help", :as => "dtext_help" - get "/static/terms_of_service" => redirect { "/terms_of_service" } + get "/static/terms_of_service", to: redirect("/terms_of_service") + get "/user_upgrade/new", to: redirect("/user_upgrades/new") get "/mock/recommender/recommend/:user_id" => "mock_services#recommender_recommend", as: "mock_recommender_recommend" get "/mock/recommender/similiar/:post_id" => "mock_services#recommender_similar", as: "mock_recommender_similar" From 7fc5845e72a381b0e87df11671eafa93b35acd83 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 19:31:40 -0600 Subject: [PATCH 104/132] /emails: add more search options. Add options to search for invalid emails and emails from restricted domains. --- app/logical/email_validator.rb | 17 ++++++++-- app/models/email_address.rb | 59 +++++++++++++++++++++++++-------- app/views/emails/index.html.erb | 28 +++++++++++++--- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/app/logical/email_validator.rb b/app/logical/email_validator.rb index 35fd5c46a..be9951810 100644 --- a/app/logical/email_validator.rb +++ b/app/logical/email_validator.rb @@ -3,6 +3,10 @@ require 'resolv' module EmailValidator module_function + # https://www.regular-expressions.info/email.html + EMAIL_REGEX = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/ + POSTGRES_EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + IGNORE_DOTS = %w[gmail.com] IGNORE_PLUS_ADDRESSING = %w[gmail.com hotmail.com outlook.com live.com] IGNORE_MINUS_ADDRESSING = %w[yahoo.com] @@ -80,10 +84,17 @@ module EmailValidator "#{name}@#{domain}" end - def nondisposable?(address) - return true if Danbooru.config.email_domain_verification_list.blank? + def is_valid?(address) + address.match?(EMAIL_REGEX) + end + + def is_restricted?(address) + return false if Danbooru.config.email_domain_verification_list.blank? + domain = Mail::Address.new(address).domain - domain.in?(Danbooru.config.email_domain_verification_list.to_a) + !domain.in?(Danbooru.config.email_domain_verification_list.to_a) + rescue Mail::Field::IncompleteParseError + true end def undeliverable?(to_address, from_address: Danbooru.config.contact_email, timeout: 3) diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 7897d6335..b43f0a6b4 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -1,10 +1,7 @@ class EmailAddress < ApplicationRecord - # https://www.regular-expressions.info/email.html - EMAIL_REGEX = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/ - belongs_to :user, inverse_of: :email_address - validates :address, presence: true, confirmation: true, format: { with: EMAIL_REGEX } + validates :address, presence: true, confirmation: true, format: { with: EmailValidator::EMAIL_REGEX } validates :normalized_address, uniqueness: true validates :user_id, uniqueness: true validate :validate_deliverable, on: :deliverable @@ -23,8 +20,49 @@ class EmailAddress < ApplicationRecord super end - def nondisposable? - EmailValidator.nondisposable?(normalized_address) + def is_restricted? + EmailValidator.is_restricted?(normalized_address) + end + + def is_normalized? + address == normalized_address + end + + def is_valid? + EmailValidator.is_valid?(address) + end + + def self.restricted(restricted = true) + domains = Danbooru.config.email_domain_verification_list + domain_regex = domains.map { |domain| Regexp.escape(domain) }.join("|") + + if restricted.to_s.truthy? + where_not_regex(:normalized_address, "@(#{domain_regex})$") + elsif restricted.to_s.falsy? + where_regex(:normalized_address, "@(#{domain_regex})$") + else + all + end + end + + def self.valid(valid = true) + if valid.to_s.truthy? + where_regex(:address, EmailValidator::POSTGRES_EMAIL_REGEX.to_s) + elsif valid.to_s.falsy? + where_not_regex(:address, EmailValidator::POSTGRES_EMAIL_REGEX.to_s) + else + all + end + end + + def self.search(params) + q = search_attributes(params, :id, :created_at, :updated_at, :user, :address, :normalized_address, :is_verified, :is_deliverable) + + q = q.restricted(params[:is_restricted]) + q = q.valid(params[:is_valid]) + q = q.apply_default_order(params) + + q end def validate_deliverable @@ -34,14 +72,7 @@ class EmailAddress < ApplicationRecord end def update_user - user.update!(is_verified: is_verified? && nondisposable?) - end - - def self.search(params) - q = search_attributes(params, :id, :created_at, :updated_at, :user, :address, :normalized_address, :is_verified, :is_deliverable) - q = q.apply_default_order(params) - - q + user.update!(is_verified: is_verified? && !is_restricted?) end concerning :VerificationMethods do diff --git a/app/views/emails/index.html.erb b/app/views/emails/index.html.erb index c1f7429ac..b03490a0a 100644 --- a/app/views/emails/index.html.erb +++ b/app/views/emails/index.html.erb @@ -5,8 +5,11 @@ <%= fa.input :name, label: "User Name", input_html: { value: params.dig(:search, :user, :name), "data-autocomplete": "user" } %> <% end %> - <%= f.input :address_ilike, label: "Address", input_html: { value: params[:search][:address] }, hint: "Use * for wildcard" %> - <%= f.input :is_verified, label: "Verified?", collection: %w[Yes No], selected: params[:search][:is_verified] %> + <%= f.input :address_ilike, label: "Address", input_html: { value: params[:search][:address_ilike] }, hint: "Use * for wildcard" %> + <%= f.input :normalized_address_ilike, label: "Normalized Address", input_html: { value: params[:search][:normalized_address_ilike] }, hint: "Use * for wildcard" %> + <%= f.input :is_valid, label: "Valid?", as: :select, include_blank: true, selected: params[:search][:is_valid] %> + <%= f.input :is_verified, label: "Verified?", as: :select, include_blank: true, selected: params[:search][:is_verified] %> + <%= f.input :is_restricted, label: "Restricted?", as: :select, include_blank: true, selected: params[:search][:is_restricted] %> <%= f.submit "Search" %> <% end %> @@ -14,8 +17,25 @@ <% t.column :user do |email| %> <%= link_to_user email.user %> <% end %> - <% t.column :address %> - <% t.column :is_verified, name: "Verified?" do |email| %> + <% t.column :address do |email| %> + <%= link_to email.address, emails_path(search: { address_ilike: email.address }) %> + <% end %> + <% t.column :normalized_address do |email| %> + <% unless email.is_normalized? %> + <%= link_to email.normalized_address, emails_path(search: { normalized_address_ilike: email.normalized_address }) %> + <% end %> + <% end %> + <% t.column "Valid?" do |email| %> + <% if !email.is_valid? %> + <%= link_to "No", emails_path(search: { is_valid: false }) %> + <% end %> + <% end %> + <% t.column "Restricted?" do |email| %> + <% if email.is_restricted? %> + <%= link_to "Yes", emails_path(search: { is_restricted: true }) %> + <% end %> + <% end %> + <% t.column "Verified?" do |email| %> <% if email.is_verified? %> <%= link_to "Yes", emails_path(search: { is_verified: true }) %> <% else %> From e29e2da8beea14d3555a484fed28ec421030fbb3 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 19:50:17 -0600 Subject: [PATCH 105/132] /user_upgrades: add json/xml api support. --- app/controllers/user_upgrades_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 80c8fa303..c3f762d0a 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -1,5 +1,5 @@ class UserUpgradesController < ApplicationController - respond_to :js, :html + respond_to :js, :html, :json, :xml def create @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) From 87af02f68975f53212548e1e30afb42b92605651 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 28 Dec 2020 22:25:09 -0600 Subject: [PATCH 106/132] user upgrades: add links to Stripe payment & receipt page. Add links to the Stripe payment page and the Stripe receipt page on completed user upgrades. The Stripe payment link is a link to the payment details on the Stripe dashboard and is only visible to the owner. --- app/controllers/user_upgrades_controller.rb | 10 +++ app/models/user_upgrade.rb | 26 ++++++++ app/policies/user_upgrade_policy.rb | 8 +++ app/views/user_upgrades/index.html.erb | 14 ++++- app/views/user_upgrades/show.html.erb | 12 +++- config/routes.rb | 5 +- .../user_upgrades_controller_test.rb | 62 +++++++++++++++++++ test/unit/user_upgrade_test.rb | 23 +++++++ 8 files changed, 154 insertions(+), 6 deletions(-) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index c3f762d0a..37d8ef5cd 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -27,6 +27,16 @@ class UserUpgradesController < ApplicationController respond_with(@user_upgrade) end + def receipt + @user_upgrade = authorize UserUpgrade.find(params[:id]) + redirect_to @user_upgrade.receipt_url + end + + def payment + @user_upgrade = authorize UserUpgrade.find(params[:id]) + redirect_to @user_upgrade.payment_url + end + private def recipient diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 7ccd87987..a69c95dce 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -198,6 +198,32 @@ class UserUpgrade < ApplicationRecord checkout end + def receipt_url + return nil if pending? || stripe_id.nil? + + checkout_session = Stripe::Checkout::Session.retrieve(stripe_id) + payment_intent = Stripe::PaymentIntent.retrieve(checkout_session.payment_intent) + charge = payment_intent.charges.data.first + charge.receipt_url + end + + def payment_url + return nil if pending? || stripe_id.nil? + + checkout_session = Stripe::Checkout::Session.retrieve(stripe_id) + payment_intent = Stripe::PaymentIntent.retrieve(checkout_session.payment_intent) + + "https://dashboard.stripe.com/payments/#{payment_intent.id}" + end + + def has_receipt? + !pending? + end + + def has_payment? + !pending? + end + class_methods do def register_webhook webhook = Stripe::WebhookEndpoint.create({ diff --git a/app/policies/user_upgrade_policy.rb b/app/policies/user_upgrade_policy.rb index a6568f5db..1da56ef5c 100644 --- a/app/policies/user_upgrade_policy.rb +++ b/app/policies/user_upgrade_policy.rb @@ -10,4 +10,12 @@ class UserUpgradePolicy < ApplicationPolicy def show? record.recipient == user || record.purchaser == user || user.is_owner? end + + def receipt? + (record.purchaser == user || user.is_owner?) && record.has_receipt? + end + + def payment? + user.is_owner? && record.has_payment? + end end diff --git a/app/views/user_upgrades/index.html.erb b/app/views/user_upgrades/index.html.erb index 611ea071e..ea57f6ba2 100644 --- a/app/views/user_upgrades/index.html.erb +++ b/app/views/user_upgrades/index.html.erb @@ -28,8 +28,18 @@ <% t.column :status %> - <% t.column "Updated" do |artist| %> - <%= time_ago_in_words_tagged(artist.updated_at) %> + <% t.column "Updated" do |user_upgrade| %> + <%= time_ago_in_words_tagged(user_upgrade.updated_at) %> + <% end %> + + <% t.column column: "control" do |user_upgrade| %> + <%= link_to "Show", user_upgrade %> + <% if policy(user_upgrade).receipt? %> + | <%= link_to "Receipt", receipt_user_upgrade_path(user_upgrade), target: "_blank" %> + <% end %> + <% if policy(user_upgrade).payment? %> + | <%= link_to "Payment", payment_user_upgrade_path(user_upgrade), target: "_blank" %> + <% end %> <% end %> <% end %> diff --git a/app/views/user_upgrades/show.html.erb b/app/views/user_upgrades/show.html.erb index 7eeddffdf..1b342ba96 100644 --- a/app/views/user_upgrades/show.html.erb +++ b/app/views/user_upgrades/show.html.erb @@ -30,12 +30,18 @@ <% if @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.recipient %>

    <%= link_to_user @user_upgrade.purchaser %> has upgraded your account to <%= @user_upgrade.level_string %>. Enjoy your new account!

    <% elsif @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.purchaser %> -

    <%= link_to_user @user_upgrade.recipient %> is now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site!

    +

    <%= link_to_user @user_upgrade.recipient %> is now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site! A receipt has been sent to your email.

    <% else %> -

    You are now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site!

    +

    You are now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site! A receipt has been sent to your email.

    <% end %> -

    <%= link_to "Go back to #{Danbooru.config.canonical_app_name}", "https://danbooru.donmai.us" %> to continue using the site.

    + <% if policy(@user_upgrade).receipt? %> + <%= link_to "View Receipt", receipt_user_upgrade_path(@user_upgrade), target: "_blank" %> + <% end %> + + <% if policy(@user_upgrade).payment? %> + | <%= link_to "View Payment", payment_user_upgrade_path(@user_upgrade), target: "_blank" %> + <% end %> <% else %> <%= content_for :html_header do %> diff --git a/config/routes.rb b/config/routes.rb index cd1b3e587..ede8fa5bf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -254,7 +254,10 @@ Rails.application.routes.draw do get :custom_style end end - resources :user_upgrades, only: [:new, :create, :show, :index] + resources :user_upgrades, only: [:new, :create, :show, :index] do + get :receipt, on: :member + get :payment, on: :member + end resources :user_feedbacks, except: [:destroy] resources :user_name_change_requests, only: [:new, :create, :show, :index] resources :webhooks do diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index 75ff4f2f1..c1a02213a 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -121,6 +121,68 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end end + context "receipt action" do + mock_stripe! + + setup do + @user_upgrade = create(:gift_gold_upgrade, status: "complete") + @user_upgrade.create_checkout! + end + + should "not allow unauthorized users to view the receipt" do + get_auth receipt_user_upgrade_path(@user_upgrade), create(:user) + + assert_response 403 + end + + should "not allow the recipient to view the receipt" do + get_auth receipt_user_upgrade_path(@user_upgrade), @user_upgrade.recipient + + assert_response 403 + end + + should "not allow the purchaser to view a pending receipt" do + @user_upgrade.update!(status: "pending") + get_auth receipt_user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response 403 + end + + # XXX not supported yet by stripe-ruby-mock + should_eventually "allow the purchaser to view the receipt" do + get_auth receipt_user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_redirected_to "xxx" + end + + # XXX not supported yet by stripe-ruby-mock + should_eventually "allow the site owner to view the receipt" do + get_auth receipt_user_upgrade_path(@user_upgrade), create(:owner_user) + + assert_redirected_to "xxx" + end + end + + context "payment action" do + setup do + @user_upgrade = create(:gift_gold_upgrade, status: "complete") + @user_upgrade.create_checkout! + end + + should "not allow unauthorized users to view the receipt" do + get_auth payment_user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response 403 + end + + # XXX not supported yet by stripe-ruby-mock + should_eventually "allow the site owner to view the receipt" do + get_auth payment_user_upgrade_path(@user_upgrade), create(:owner_user) + + assert_redirected_to "xxx" + end + end + context "create action" do mock_stripe! diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb index 93666aebb..f90cbd4da 100644 --- a/test/unit/user_upgrade_test.rb +++ b/test/unit/user_upgrade_test.rb @@ -59,5 +59,28 @@ class UserUpgradeTest < ActiveSupport::TestCase end end end + + context "the #receipt_url method" do + mock_stripe! + + context "a pending upgrade" do + should "not have a receipt" do + @user_upgrade = create(:self_gold_upgrade, status: "pending") + @user_upgrade.create_checkout! + + assert_equal(nil, @user_upgrade.receipt_url) + end + end + + context "a complete upgrade" do + # XXX not supported yet by stripe-ruby-mock + should_eventually "have a receipt" do + @user_upgrade = create(:self_gold_upgrade, status: "complete") + @user_upgrade.create_checkout! + + assert_equal("xxx", @user_upgrade.receipt_url) + end + end + end end end From 4b171bf97ea61cdb4b195c44216c664f98d20a17 Mon Sep 17 00:00:00 2001 From: evazion Date: Tue, 29 Dec 2020 03:50:43 -0600 Subject: [PATCH 107/132] user upgrades: add ability to refund upgrades. --- app/controllers/user_upgrades_controller.rb | 8 ++++ app/models/user_upgrade.rb | 48 +++++++++++++++---- app/policies/user_upgrade_policy.rb | 4 ++ app/views/user_upgrades/index.html.erb | 3 ++ app/views/user_upgrades/refund.js.erb | 1 + config/routes.rb | 1 + .../user_upgrades_controller_test.rb | 45 +++++++++++++++++ test/unit/user_upgrade_test.rb | 11 +++++ 8 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 app/views/user_upgrades/refund.js.erb diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index 37d8ef5cd..c2e5f0160 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -27,6 +27,14 @@ class UserUpgradesController < ApplicationController respond_with(@user_upgrade) end + def refund + @user_upgrade = authorize UserUpgrade.find(params[:id]) + @user_upgrade.refund! + flash[:notice] = "Upgrade refunded" + + respond_with(@user_upgrade) + end + def receipt @user_upgrade = authorize UserUpgrade.find(params[:id]) redirect_to @user_upgrade.receipt_url diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index a69c95dce..9ed86e4ed 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -11,7 +11,8 @@ class UserUpgrade < ApplicationRecord enum status: { pending: 0, processing: 10, - complete: 20 + complete: 20, + refunded: 30, } scope :gifted, -> { where("recipient_id != purchaser_id") } @@ -62,6 +63,19 @@ class UserUpgrade < ApplicationRecord end end + def previous_level + case upgrade_type + when "gold" + User::Levels::MEMBER + when "platinum" + User::Levels::MEMBER + when "gold_to_platinum" + User::Levels::GOLD + else + raise NotImplementedError + end + end + def upgrade_price case upgrade_type when "gold" @@ -120,7 +134,7 @@ class UserUpgrade < ApplicationRecord concerning :UpgradeMethods do def process_upgrade!(payment_status) recipient.with_lock do - return if status == "complete" + return unless pending? || processing? if payment_status == "paid" upgrade_recipient! @@ -198,24 +212,38 @@ class UserUpgrade < ApplicationRecord checkout end + def refund!(reason: nil) + with_lock do + return if refunded? + + Stripe::Refund.create(payment_intent: payment_intent.id, reason: reason) + recipient.update!(level: previous_level) + update!(status: "refunded") + end + end + def receipt_url return nil if pending? || stripe_id.nil? - - checkout_session = Stripe::Checkout::Session.retrieve(stripe_id) - payment_intent = Stripe::PaymentIntent.retrieve(checkout_session.payment_intent) - charge = payment_intent.charges.data.first charge.receipt_url end def payment_url return nil if pending? || stripe_id.nil? - - checkout_session = Stripe::Checkout::Session.retrieve(stripe_id) - payment_intent = Stripe::PaymentIntent.retrieve(checkout_session.payment_intent) - "https://dashboard.stripe.com/payments/#{payment_intent.id}" end + def checkout_session + @checkout_session ||= Stripe::Checkout::Session.retrieve(stripe_id) + end + + def payment_intent + @payment_intent ||= Stripe::PaymentIntent.retrieve(checkout_session.payment_intent) + end + + def charge + payment_intent.charges.data.first + end + def has_receipt? !pending? end diff --git a/app/policies/user_upgrade_policy.rb b/app/policies/user_upgrade_policy.rb index 1da56ef5c..d5022a350 100644 --- a/app/policies/user_upgrade_policy.rb +++ b/app/policies/user_upgrade_policy.rb @@ -11,6 +11,10 @@ class UserUpgradePolicy < ApplicationPolicy record.recipient == user || record.purchaser == user || user.is_owner? end + def refund? + user.is_owner? && record.complete? + end + def receipt? (record.purchaser == user || user.is_owner?) && record.has_receipt? end diff --git a/app/views/user_upgrades/index.html.erb b/app/views/user_upgrades/index.html.erb index ea57f6ba2..2a0448406 100644 --- a/app/views/user_upgrades/index.html.erb +++ b/app/views/user_upgrades/index.html.erb @@ -40,6 +40,9 @@ <% if policy(user_upgrade).payment? %> | <%= link_to "Payment", payment_user_upgrade_path(user_upgrade), target: "_blank" %> <% end %> + <% if policy(user_upgrade).refund? %> + | <%= link_to "Refund", refund_user_upgrade_path(user_upgrade), remote: true, method: :put, "data-confirm": "Are you sure you want to refund this payment?" %> + <% end %> <% end %> <% end %> diff --git a/app/views/user_upgrades/refund.js.erb b/app/views/user_upgrades/refund.js.erb new file mode 100644 index 000000000..345366b9b --- /dev/null +++ b/app/views/user_upgrades/refund.js.erb @@ -0,0 +1 @@ +location.reload(); diff --git a/config/routes.rb b/config/routes.rb index ede8fa5bf..13e8a809c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -257,6 +257,7 @@ Rails.application.routes.draw do resources :user_upgrades, only: [:new, :create, :show, :index] do get :receipt, on: :member get :payment, on: :member + put :refund, on: :member end resources :user_feedbacks, except: [:destroy] resources :user_name_change_requests, only: [:new, :create, :show, :index] diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index c1a02213a..8075eb7e0 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -183,6 +183,51 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end end + context "refund action" do + mock_stripe! + + context "for a self upgrade" do + context "to Gold" do + should_eventually "refund the upgrade" do + @user_upgrade = create(:self_gold_upgrade, recipient: create(:gold_user), status: "complete") + @user_upgrade.create_checkout! + + put_auth refund_user_upgrade_path(@user_upgrade), create(:owner_user), xhr: true + + assert_response :success + assert_equal("refunded", @user_upgrade.reload.status) + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.level) + end + end + end + + context "for a gifted upgrade" do + context "to Platinum" do + should_eventually "refund the upgrade" do + @user_upgrade = create(:gift_platinum_upgrade, recipient: create(:platinum_user), status: "complete") + @user_upgrade.create_checkout! + + put_auth refund_user_upgrade_path(@user_upgrade), create(:owner_user), xhr: true + + assert_response :success + assert_equal("refunded", @user_upgrade.reload.status) + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.level) + end + end + end + + should "not allow unauthorized users to create a refund" do + @user_upgrade = create(:self_gold_upgrade, recipient: create(:gold_user), status: "complete") + @user_upgrade.create_checkout! + + put_auth refund_user_upgrade_path(@user_upgrade), @user_upgrade.purchaser, xhr: true + + assert_response 403 + assert_equal("complete", @user_upgrade.reload.status) + assert_equal(User::Levels::GOLD, @user_upgrade.recipient.level) + end + end + context "create action" do mock_stripe! diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb index f90cbd4da..38102d2bf 100644 --- a/test/unit/user_upgrade_test.rb +++ b/test/unit/user_upgrade_test.rb @@ -82,5 +82,16 @@ class UserUpgradeTest < ActiveSupport::TestCase end end end + + context "the #refund! method" do + should_eventually "refund a Gold upgrade" do + @user_upgrade = create(:self_gold_upgrade, recipient: create(:gold_user), status: "complete") + @user_upgrade.create_checkout! + @user_upgrade.refund! + + assert_equal("refunded", @user_upgrade.reload.status) + assert_equal(User::Levels::MEMBER, @user_upgrade.recipient.level) + end + end end end From 9e9ac8f4bf9704f4a6b7cbed13ac82901cccd4ff Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 01:43:29 -0600 Subject: [PATCH 108/132] sessions: store geolocated country in CurrentUser. --- app/logical/current_user.rb | 8 ++++++++ app/logical/danbooru_logger.rb | 2 +- app/logical/session_loader.rb | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/logical/current_user.rb b/app/logical/current_user.rb index 4c8d80349..5145ec7a7 100644 --- a/app/logical/current_user.rb +++ b/app/logical/current_user.rb @@ -40,6 +40,14 @@ class CurrentUser RequestStore[:current_ip_addr] = ip_addr end + def self.country + RequestStore[:country] + end + + def self.country=(country) + RequestStore[:country] = country + end + def self.root_url RequestStore[:current_root_url] || "https://#{Danbooru.config.hostname}" end diff --git a/app/logical/danbooru_logger.rb b/app/logical/danbooru_logger.rb index 63f8c2179..bbe5b40a8 100644 --- a/app/logical/danbooru_logger.rb +++ b/app/logical/danbooru_logger.rb @@ -51,7 +51,7 @@ class DanbooruLogger name: user&.name, level: user&.level_string, ip: request.remote_ip, - country: request.headers["CF-IPCountry"], + country: CurrentUser.country, safe_mode: CurrentUser.safe_mode? } end diff --git a/app/logical/session_loader.rb b/app/logical/session_loader.rb index 2407d5520..12f12e189 100644 --- a/app/logical/session_loader.rb +++ b/app/logical/session_loader.rb @@ -34,6 +34,7 @@ class SessionLoader update_last_logged_in_at update_last_ip_addr set_time_zone + set_country set_safe_mode initialize_session_cookies CurrentUser.user.unban! if CurrentUser.user.ban_expired? @@ -101,6 +102,12 @@ class SessionLoader Time.zone = CurrentUser.user.time_zone end + # Depends on Cloudflare + # https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation + def set_country + CurrentUser.country = request.headers["CF-IPCountry"] + end + def set_safe_mode safe_mode = request.host.match?(/safebooru/i) || params[:safe_mode].to_s.truthy? || CurrentUser.user.enable_safe_mode? CurrentUser.safe_mode = safe_mode From ae5c0d1034e58985b4a62d53a4235194d2d2c748 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 01:49:49 -0600 Subject: [PATCH 109/132] newrelic: log request path. --- app/logical/danbooru_logger.rb | 1 + test/unit/session_loader_test.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/app/logical/danbooru_logger.rb b/app/logical/danbooru_logger.rb index bbe5b40a8..30989d162 100644 --- a/app/logical/danbooru_logger.rb +++ b/app/logical/danbooru_logger.rb @@ -24,6 +24,7 @@ class DanbooruLogger end def self.add_session_attributes(request, session, user) + add_attributes("request", { path: request.path }) add_attributes("request.headers", header_params(request)) add_attributes("request.params", request_params(request)) add_attributes("session.params", session_params(session)) diff --git a/test/unit/session_loader_test.rb b/test/unit/session_loader_test.rb index 984ea6cf1..6706c6e9c 100644 --- a/test/unit/session_loader_test.rb +++ b/test/unit/session_loader_test.rb @@ -6,6 +6,7 @@ class SessionLoaderTest < ActiveSupport::TestCase @request = mock @request.stubs(:host).returns("danbooru") @request.stubs(:remote_ip).returns("127.0.0.1") + @request.stubs(:path).returns("/") @request.stubs(:authorization).returns(nil) @request.stubs(:cookie_jar).returns({}) @request.stubs(:parameters).returns({}) From bf09940a55c353fe0b6b2f346e024a70e90837cb Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 02:00:31 -0600 Subject: [PATCH 110/132] debug mode: re-raise exceptions in controller. Fixes exception information not reaching the console during failed controller tests. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ad4f918b0..af1317760 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -88,7 +88,7 @@ class ApplicationController < ActionController::Base end def rescue_exception(exception) - return if Danbooru.config.debug_mode + raise exception if Danbooru.config.debug_mode case exception when ActionView::Template::Error From d0bb4ed39895754b323c884eea94a2555e8506ed Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 04:32:07 -0600 Subject: [PATCH 111/132] user upgrades: add bank payment methods for European countries. Add the following bank redirect payment methods: * https://stripe.com/docs/payments/bancontact * https://stripe.com/docs/payments/eps * https://stripe.com/docs/payments/giropay * https://stripe.com/docs/payments/ideal * https://stripe.com/docs/payments/p24 These methods are used in Austria, Belgium, Germany, the Netherlands, and Poland. These methods require payments to be denominated in EUR, which means we have to set prices in both USD and EUR, and we have to automatically detect which currency to use based on the user's country. We also have to automatically detect which payment methods to offer based on the user's country. We do this by using Cloudflare's CF-IPCountry header to geolocate the user's country. This also switches to using prices and products defined in Stripe instead of generated on-the-fly when creating the checkout. --- app/controllers/user_upgrades_controller.rb | 3 +- app/models/user_upgrade.rb | 103 ++++++++++++------ app/views/user_upgrades/new.html.erb | 11 +- config/danbooru_default_config.rb | 21 ++++ test/functional/webhooks_controller_test.rb | 8 +- test/test_helpers/stripe_test_helper.rb | 4 +- test/unit/user_upgrade_test.rb | 112 ++++++++++++++++++++ 7 files changed, 219 insertions(+), 43 deletions(-) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index c2e5f0160..e0e86e566 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -3,7 +3,8 @@ class UserUpgradesController < ApplicationController def create @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) - @checkout = @user_upgrade.create_checkout! + @country = params[:country] || CurrentUser.country || "US" + @checkout = @user_upgrade.create_checkout!(country: @country) respond_with(@user_upgrade) end diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 9ed86e4ed..69f580f5c 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -76,32 +76,6 @@ class UserUpgrade < ApplicationRecord end end - def upgrade_price - case upgrade_type - when "gold" - UserUpgrade.gold_price - when "platinum" - UserUpgrade.platinum_price - when "gold_to_platinum" - UserUpgrade.gold_to_platinum_price - else - raise NotImplementedError - end - end - - def upgrade_description - case upgrade_type - when "gold" - "Upgrade to Gold" - when "platinum" - "Upgrade to Platinum" - when "gold_to_platinum" - "Upgrade Gold to Platinum" - else - raise NotImplementedError - end - end - def level_string User.level_string(level) end @@ -178,24 +152,25 @@ class UserUpgrade < ApplicationRecord end concerning :StripeMethods do - def create_checkout! + def create_checkout!(country: "US") + methods = payment_method_types(country) + currency = preferred_currency(country) + price_id = upgrade_price_id(currency) + checkout = Stripe::Checkout::Session.create( mode: "payment", success_url: Routes.user_upgrade_url(self), cancel_url: Routes.new_user_upgrade_url(user_id: recipient.id), client_reference_id: "user_upgrade_#{id}", customer_email: purchaser.email_address&.address, - payment_method_types: ["card"], + payment_method_types: methods, line_items: [{ - price_data: { - unit_amount: upgrade_price, - currency: "usd", - product_data: { - name: upgrade_description, - }, - }, + price: price_id, quantity: 1, }], + discounts: [{ + coupon: promotion_discount_id, + }], metadata: { user_upgrade_id: id, purchaser_id: purchaser.id, @@ -203,6 +178,7 @@ class UserUpgrade < ApplicationRecord purchaser_name: purchaser.name, recipient_name: recipient.name, upgrade_type: upgrade_type, + country: country, is_gift: is_gift?, level: level, }, @@ -252,6 +228,63 @@ class UserUpgrade < ApplicationRecord !pending? end + def promotion_discount_id + if Danbooru.config.is_promotion? + Danbooru.config.stripe_promotion_discount_id + end + end + + def upgrade_price_id(currency) + case [upgrade_type, currency] + when ["gold", "usd"] + Danbooru.config.stripe_gold_usd_price_id + when ["gold", "eur"] + Danbooru.config.stripe_gold_eur_price_id + when ["platinum", "usd"] + Danbooru.config.stripe_platinum_usd_price_id + when ["platinum", "eur"] + Danbooru.config.stripe_platinum_eur_price_id + when ["gold_to_platinum", "usd"] + Danbooru.config.stripe_gold_to_platinum_usd_price_id + when ["gold_to_platinum", "eur"] + Danbooru.config.stripe_gold_to_platinum_eur_price_id + else + raise NotImplementedError + end + end + + def payment_method_types(country) + case country.to_s.upcase + # Austria, https://stripe.com/docs/payments/bancontact + when "AT" + ["card", "eps"] + # Belgium, https://stripe.com/docs/payments/eps + when "BE" + ["card", "bancontact"] + # Germany, https://stripe.com/docs/payments/giropay + when "DE" + ["card", "giropay"] + # Netherlands, https://stripe.com/docs/payments/ideal + when "NL" + ["card", "ideal"] + # Poland, https://stripe.com/docs/payments/p24 + when "PL" + ["card", "p24"] + else + ["card"] + end + end + + def preferred_currency(country) + case country.to_s.upcase + # Austria, Belgium, Germany, Netherlands, Poland + when "AT", "BE", "DE", "NL", "PL" + "eur" + else + "usd" + end + end + class_methods do def register_webhook webhook = Stripe::WebhookEndpoint.create({ diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 6f125ebed..31a85d34d 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -112,12 +112,12 @@ <%= link_to "Get #{Danbooru.config.canonical_app_name} Platinum", login_path(url: new_user_upgrade_path), class: "login-button" %> <% elsif @recipient.level == User::Levels::MEMBER %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold"), remote: true, disable_with: "Redirecting..." %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "platinum"), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country]), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "platinum", country: params[:country]), remote: true, disable_with: "Redirecting..." %> <% elsif @recipient.level == User::Levels::GOLD %> <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold_to_platinum"), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold_to_platinum", country: params[:country]), remote: true, disable_with: "Redirecting..." %> <% else %> <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %> @@ -150,7 +150,10 @@ What payment methods do you support?

    We support all major credit and debit cards, including international - cards. Payments are securely handled by Stripe. + cards. We also support bank payments in several European countries, + including Austria, Belgium, Germany, the Netherlands, and Poland.

    + +

    Payments are securely handled by Stripe. We don't support PayPal or Bitcoin at this time.

    diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 947dc75c1..3467e4dcc 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -375,6 +375,27 @@ module Danbooru def stripe_webhook_secret end + def stripe_gold_usd_price_id + end + + def stripe_platinum_usd_price_id + end + + def stripe_gold_to_platinum_usd_price_id + end + + def stripe_gold_eur_price_id + end + + def stripe_platinum_eur_price_id + end + + def stripe_gold_to_platinum_eur_price_id + end + + def stripe_promotion_discount_id + end + def twitter_api_key end diff --git a/test/functional/webhooks_controller_test.rb b/test/functional/webhooks_controller_test.rb index dd10f8df8..3ac99ae1e 100644 --- a/test/functional/webhooks_controller_test.rb +++ b/test/functional/webhooks_controller_test.rb @@ -1,7 +1,13 @@ require 'test_helper' class WebhooksControllerTest < ActionDispatch::IntegrationTest - mock_stripe! + setup do + StripeMock.start + end + + teardown do + StripeMock.stop + end def post_webhook(*args, payment_status: "paid", **metadata) event = StripeMock.mock_webhook_event(*args, payment_status: payment_status, metadata: metadata) diff --git a/test/test_helpers/stripe_test_helper.rb b/test/test_helpers/stripe_test_helper.rb index d1f0f7126..9bfc845f9 100644 --- a/test/test_helpers/stripe_test_helper.rb +++ b/test/test_helpers/stripe_test_helper.rb @@ -3,11 +3,11 @@ StripeMock.webhook_fixture_path = "test/fixtures/stripe-webhooks" module StripeTestHelper def mock_stripe! setup do - StripeMock.start + StripeMock.start unless UserUpgrade.enabled? end teardown do - StripeMock.stop + StripeMock.stop unless UserUpgrade.enabled? end end end diff --git a/test/unit/user_upgrade_test.rb b/test/unit/user_upgrade_test.rb index 38102d2bf..d73eee270 100644 --- a/test/unit/user_upgrade_test.rb +++ b/test/unit/user_upgrade_test.rb @@ -58,6 +58,116 @@ class UserUpgradeTest < ActiveSupport::TestCase end end end + + context "for each upgrade type" do + setup do + skip unless UserUpgrade.enabled? + end + + should "choose the right price in USD for a gold upgrade" do + @user_upgrade = create(:self_gold_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "US") + + assert_equal(UserUpgrade.gold_price, @user_upgrade.payment_intent.amount) + assert_equal("usd", @user_upgrade.payment_intent.currency) + end + + should "choose the right price in USD for a platinum upgrade" do + @user_upgrade = create(:self_platinum_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "US") + + assert_equal(UserUpgrade.platinum_price, @user_upgrade.payment_intent.amount) + assert_equal("usd", @user_upgrade.payment_intent.currency) + end + + should "choose the right price in USD for a gold to platinum upgrade" do + @user_upgrade = create(:self_gold_to_platinum_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "US") + + assert_equal(UserUpgrade.gold_to_platinum_price, @user_upgrade.payment_intent.amount) + assert_equal("usd", @user_upgrade.payment_intent.currency) + end + + should "choose the right price in EUR for a gold upgrade" do + @user_upgrade = create(:self_gold_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "DE") + + assert_equal(0.8 * UserUpgrade.gold_price, @user_upgrade.payment_intent.amount) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right price in EUR for a platinum upgrade" do + @user_upgrade = create(:self_platinum_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "DE") + + assert_equal(0.8 * UserUpgrade.platinum_price, @user_upgrade.payment_intent.amount) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right price in EUR for a gold to platinum upgrade" do + @user_upgrade = create(:self_gold_to_platinum_upgrade) + @checkout = @user_upgrade.create_checkout!(country: "DE") + + assert_equal(0.8 * UserUpgrade.gold_to_platinum_price, @user_upgrade.payment_intent.amount) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + end + + context "for each country" do + setup do + @user_upgrade = create(:self_gold_upgrade) + skip unless UserUpgrade.enabled? + end + + should "choose the right payment methods for US" do + @checkout = @user_upgrade.create_checkout!(country: "US") + + assert_equal(["card"], @checkout.payment_method_types) + assert_equal("usd", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for AT" do + @checkout = @user_upgrade.create_checkout!(country: "AT") + + assert_equal(["card", "eps"], @checkout.payment_method_types) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for BE" do + @checkout = @user_upgrade.create_checkout!(country: "BE") + + assert_equal(["card", "bancontact"], @checkout.payment_method_types) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for DE" do + @checkout = @user_upgrade.create_checkout!(country: "DE") + + assert_equal(["card", "giropay"], @checkout.payment_method_types) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for NL" do + @checkout = @user_upgrade.create_checkout!(country: "NL") + + assert_equal(["card", "ideal"], @checkout.payment_method_types) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for PL" do + @checkout = @user_upgrade.create_checkout!(country: "PL") + + assert_equal(["card", "p24"], @checkout.payment_method_types) + assert_equal("eur", @user_upgrade.payment_intent.currency) + end + + should "choose the right payment methods for an unsupported country" do + @checkout = @user_upgrade.create_checkout!(country: "MX") + + assert_equal(["card"], @checkout.payment_method_types) + assert_equal("usd", @user_upgrade.payment_intent.currency) + end + end end context "the #receipt_url method" do @@ -65,6 +175,8 @@ class UserUpgradeTest < ActiveSupport::TestCase context "a pending upgrade" do should "not have a receipt" do + skip unless UserUpgrade.enabled? + @user_upgrade = create(:self_gold_upgrade, status: "pending") @user_upgrade.create_checkout! From d9a8fc99bcff43075f7ddf6dc510e97fbe4c9de3 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 06:20:46 -0600 Subject: [PATCH 112/132] javascript: change Cookie.put to take expiry in seconds. --- app/javascript/src/javascripts/common.js | 8 ++++---- app/javascript/src/javascripts/cookie.js | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/javascript/src/javascripts/common.js b/app/javascript/src/javascripts/common.js index 94b6bb8d0..e0fc4d0c0 100644 --- a/app/javascript/src/javascripts/common.js +++ b/app/javascript/src/javascripts/common.js @@ -3,14 +3,14 @@ import Cookie from './cookie' $(function() { $("#hide-upgrade-account-notice").on("click.danbooru", function(e) { $("#upgrade-account-notice").hide(); - Cookie.put('hide_upgrade_account_notice', '1', 7); + Cookie.put('hide_upgrade_account_notice', '1', 7 * 24 * 60 * 60); e.preventDefault(); }); $("#hide-promotion-notice").on("click.danbooru", function(e) { $("#promotion-notice").hide(); - Cookie.put("hide_promotion_notice", "1", 1); - Cookie.put("hide_upgrade_account_notice", "1", 1); + Cookie.put("hide_promotion_notice", "1", 1 * 24 * 60 * 60); + Cookie.put("hide_upgrade_account_notice", "1", 1 * 24 * 60 * 60); e.preventDefault(); }); @@ -24,7 +24,7 @@ $(function() { $("#hide-verify-account-notice").on("click.danbooru", function(e) { $("#verify-account-notice").hide(); - Cookie.put('hide_verify_account_notice', '1', 3); + Cookie.put('hide_verify_account_notice', '1', 3 * 24 * 60 * 60); e.preventDefault(); }); diff --git a/app/javascript/src/javascripts/cookie.js b/app/javascript/src/javascripts/cookie.js index 707d95389..11fa8a2d4 100644 --- a/app/javascript/src/javascripts/cookie.js +++ b/app/javascript/src/javascripts/cookie.js @@ -1,10 +1,10 @@ let Cookie = {}; -Cookie.put = function(name, value, max_age_in_days = 365 * 20) { +Cookie.put = function(name, value, max_age_in_seconds = 60 * 60 * 24 * 365 * 20) { let cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax;`; - if (max_age_in_days) { - cookie += ` Max-Age=${max_age_in_days * 24 * 60 * 60};` + if (max_age_in_seconds) { + cookie += ` Max-Age=${max_age_in_seconds};` } if (location.protocol === "https:") { From 83d6cd598006aa3b713c710cc29743fe46233a20 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 31 Dec 2020 06:34:51 -0600 Subject: [PATCH 113/132] Update Winter Sale banner for last day. --- app/helpers/application_helper.rb | 6 +++++- app/javascript/src/javascripts/common.js | 4 ++-- app/views/layouts/default.html.erb | 19 ++++++------------- app/views/user_upgrades/new.html.erb | 6 +++++- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 57f4819c0..a288ee64f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -108,13 +108,17 @@ module ApplicationHelper tag.time content || datetime, datetime: datetime, title: time.to_formatted_s, **options end - def humanized_duration(from, to) + def humanized_duration(from, to, compact: false) if to - from > 10.years duration = "forever" else duration = distance_of_time_in_words(from, to) end + if compact + duration = duration.gsub(/almost|about|over/, "").gsub(/less than a/, "<1").strip + end + datetime = from.iso8601 + "/" + to.iso8601 title = "#{from.strftime("%Y-%m-%d %H:%M")} to #{to.strftime("%Y-%m-%d %H:%M")}" diff --git a/app/javascript/src/javascripts/common.js b/app/javascript/src/javascripts/common.js index e0fc4d0c0..cc04ab125 100644 --- a/app/javascript/src/javascripts/common.js +++ b/app/javascript/src/javascripts/common.js @@ -9,8 +9,8 @@ $(function() { $("#hide-promotion-notice").on("click.danbooru", function(e) { $("#promotion-notice").hide(); - Cookie.put("hide_promotion_notice", "1", 1 * 24 * 60 * 60); - Cookie.put("hide_upgrade_account_notice", "1", 1 * 24 * 60 * 60); + Cookie.put("hide_final_promotion_notice", "1", 3 * 60 * 60); + Cookie.put("hide_upgrade_account_notice", "1", 7 * 24 * 60 * 60); e.preventDefault(); }); diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index 778a3c143..7c6606f56 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -70,21 +70,14 @@
    <%= render "users/verification_notice" %> - <% if Danbooru.config.is_promotion? && cookies[:hide_promotion_notice].blank? %> + <% if Danbooru.config.is_promotion? && cookies[:hide_final_promotion_notice].blank? %>
    - <% if rand <= 0.0025 %> - <%= tag.img src: "/images/provgift.png", width: 24, height: 24 %> - <% elsif rand <= 0.0025 %> - <%= tag.img src: "/images/kemogift.png", width: 24, height: 24 %> - <% elsif rand <= 0.0025 %> - <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> - <% elsif rand <= 0.0025 %> - <%= tag.img src: "/images/ablobgift.gif", width: 24, height: 24 %> - <% else %> - <%= tag.img src: "/images/blobgift.png", width: 24, height: 24 %> - <% end %> + <% file = %w[provgift.png kemogift.png padoru.gif ablobgift.gif].sample %> + <%= tag.img src: "/images/#{file}", width: 24, height: 24 %> - <%= link_to "Danbooru Winter Sale!", forum_topic_path(17832) %> 25% off Gold and unlimited searches for Members! + <%= link_to "Danbooru Winter Sale", forum_topic_path(17832) %> ends in + <%= Time.use_zone("UTC") { humanized_duration(Time.zone.parse("2021-01-01"), Time.zone.now, compact: true) } %>! + Get 25% off Gold & Platinum (<%= link_to "hide", "#", id: "hide-promotion-notice" %>)
    <% end %> diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 31a85d34d..ed3647fe1 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -19,7 +19,11 @@

    Upgrade Account

    <% if Danbooru.config.is_promotion? %> -

    Danbooru Winter Sale! Gold and Platinum upgrades are 25% off from now until January 1st.

    +

    + <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> + Danbooru Winter Sale! Gold and Platinum upgrades are 25% off. Sale ends in <%= Time.use_zone("UTC") { humanized_duration(Time.zone.parse("2021-01-01"), Time.zone.now, compact: true) } %>. + <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> +

    <% end %>

    Upgrading your account gives you exclusive benefits and helps support From 430ba5dced2242ddbf0c1c67d76a0cb3f9e00a9f Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 03:57:17 -0600 Subject: [PATCH 114/132] users: fix exception during signup for IPv6 addresses. `ip_address.private?` failed on IPv6 addresses. --- app/logical/user_verifier.rb | 6 +++++- test/functional/users_controller_test.rb | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/logical/user_verifier.rb b/app/logical/user_verifier.rb index df0d43c93..d1e5991b9 100644 --- a/app/logical/user_verifier.rb +++ b/app/logical/user_verifier.rb @@ -23,7 +23,11 @@ class UserVerifier end def is_local_ip? - ip_address.loopback? || ip_address.link_local? || ip_address.private? || ip_address.try(:unique_local?) + if ip_address.ipv4? + ip_address.loopback? || ip_address.link_local? || ip_address.private? + elsif ip_address.ipv6? + ip_address.loopback? || ip_address.link_local? || ip_address.unique_local? + end end def is_logged_in? diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 38216a1a6..85adf6a47 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -293,9 +293,19 @@ class UsersControllerTest < ActionDispatch::IntegrationTest setup do @private_ip = "192.168.0.1" @valid_ip = "187.37.226.17" # a random valid, non-proxy public IP + @valid_ipv6 = "2600:1700:6b0:a518::1" @proxy_ip = "51.15.128.1" end + should "work for a public IPv6 address" do + self.remote_addr = @valid_ipv6 + + post users_path, params: { user: { name: "xxx", password: "xxxxx1", password_confirmation: "xxxxx1" }} + + assert_redirected_to User.last + assert_equal(false, User.last.requires_verification) + end + should "mark accounts created by already logged in users as requiring verification" do self.remote_addr = @valid_ip From 5b7894a8b2d1015d142499d2742c1058cd060520 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 04:06:38 -0600 Subject: [PATCH 115/132] autocomplete: fix exception when type param is missing. --- app/logical/autocomplete_service.rb | 2 +- test/functional/autocomplete_controller_test.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 23bb33f92..b870755b5 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -20,7 +20,7 @@ class AutocompleteService def initialize(query, type, current_user: User.anonymous, limit: 10) @query = query.to_s - @type = type.to_sym + @type = type.to_s.to_sym @current_user = current_user @limit = limit end diff --git a/test/functional/autocomplete_controller_test.rb b/test/functional/autocomplete_controller_test.rb index ae372ab1f..fa877579d 100644 --- a/test/functional/autocomplete_controller_test.rb +++ b/test/functional/autocomplete_controller_test.rb @@ -35,6 +35,13 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest assert_autocomplete_equals(["rating:safe"], "-rating:s", "tag_query") end + should "work for a missing type" do + get autocomplete_index_path(search: { query: "azur" }), as: :json + + assert_response :success + assert_equal([], response.parsed_body) + end + should "not set session cookies when the response is publicly cached" do get autocomplete_index_path(search: { query: "azur", type: "tag_query" }), as: :json From 1d15ce2bcdfea45c21a53e15d21c4098214807ac Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 04:11:49 -0600 Subject: [PATCH 116/132] Remove Danbooru Winter Sale. --- app/helpers/application_helper.rb | 6 +----- app/javascript/src/javascripts/common.js | 7 ------- app/models/user.rb | 4 +--- app/models/user_upgrade.rb | 15 +-------------- app/views/layouts/default.html.erb | 12 ------------ app/views/user_upgrades/new.html.erb | 14 -------------- config/danbooru_default_config.rb | 6 ------ public/images/blobgift.png | Bin 15830 -> 0 bytes public/images/kemogift.png | Bin 22398 -> 0 bytes public/images/padoru.gif | Bin 22445 -> 0 bytes public/images/provgift.png | Bin 16795 -> 0 bytes 11 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 public/images/blobgift.png delete mode 100644 public/images/kemogift.png delete mode 100644 public/images/padoru.gif delete mode 100644 public/images/provgift.png diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a288ee64f..57f4819c0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -108,17 +108,13 @@ module ApplicationHelper tag.time content || datetime, datetime: datetime, title: time.to_formatted_s, **options end - def humanized_duration(from, to, compact: false) + def humanized_duration(from, to) if to - from > 10.years duration = "forever" else duration = distance_of_time_in_words(from, to) end - if compact - duration = duration.gsub(/almost|about|over/, "").gsub(/less than a/, "<1").strip - end - datetime = from.iso8601 + "/" + to.iso8601 title = "#{from.strftime("%Y-%m-%d %H:%M")} to #{to.strftime("%Y-%m-%d %H:%M")}" diff --git a/app/javascript/src/javascripts/common.js b/app/javascript/src/javascripts/common.js index cc04ab125..90fc89347 100644 --- a/app/javascript/src/javascripts/common.js +++ b/app/javascript/src/javascripts/common.js @@ -7,13 +7,6 @@ $(function() { e.preventDefault(); }); - $("#hide-promotion-notice").on("click.danbooru", function(e) { - $("#promotion-notice").hide(); - Cookie.put("hide_final_promotion_notice", "1", 3 * 60 * 60); - Cookie.put("hide_upgrade_account_notice", "1", 7 * 24 * 60 * 60); - e.preventDefault(); - }); - $("#hide-dmail-notice").on("click.danbooru", function(e) { var $dmail_notice = $("#dmail-notice"); $dmail_notice.hide(); diff --git a/app/models/user.rb b/app/models/user.rb index 732bf3079..01730294c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -404,9 +404,7 @@ class User < ApplicationRecord end def tag_query_limit - if is_member? && Danbooru.config.is_promotion? - 1_000_000 - elsif is_platinum? + if is_platinum? Danbooru.config.base_tag_query_limit * 2 elsif is_gold? Danbooru.config.base_tag_query_limit diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 69f580f5c..48083c6d4 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -35,11 +35,7 @@ class UserUpgrade < ApplicationRecord end def self.gold_price - if Danbooru.config.is_promotion? - 1500 - else - 2000 - end + 2000 end def self.platinum_price @@ -168,9 +164,6 @@ class UserUpgrade < ApplicationRecord price: price_id, quantity: 1, }], - discounts: [{ - coupon: promotion_discount_id, - }], metadata: { user_upgrade_id: id, purchaser_id: purchaser.id, @@ -228,12 +221,6 @@ class UserUpgrade < ApplicationRecord !pending? end - def promotion_discount_id - if Danbooru.config.is_promotion? - Danbooru.config.stripe_promotion_discount_id - end - end - def upgrade_price_id(currency) case [upgrade_type, currency] when ["gold", "usd"] diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index 7c6606f56..c79fa8acb 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -70,18 +70,6 @@

    <%= render "users/verification_notice" %> - <% if Danbooru.config.is_promotion? && cookies[:hide_final_promotion_notice].blank? %> -
    - <% file = %w[provgift.png kemogift.png padoru.gif ablobgift.gif].sample %> - <%= tag.img src: "/images/#{file}", width: 24, height: 24 %> - - <%= link_to "Danbooru Winter Sale", forum_topic_path(17832) %> ends in - <%= Time.use_zone("UTC") { humanized_duration(Time.zone.parse("2021-01-01"), Time.zone.now, compact: true) } %>! - Get 25% off Gold & Platinum - (<%= link_to "hide", "#", id: "hide-promotion-notice" %>) -
    - <% end %> - <% if !CurrentUser.is_anonymous? && !CurrentUser.is_gold? && cookies[:hide_upgrade_account_notice].blank? && params[:action] != "upgrade_information" %> <%= render "users/upgrade_notice" %> <% end %> diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index ed3647fe1..8766dfc83 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -18,14 +18,6 @@ <% else %>

    Upgrade Account

    - <% if Danbooru.config.is_promotion? %> -

    - <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> - Danbooru Winter Sale! Gold and Platinum upgrades are 25% off. Sale ends in <%= Time.use_zone("UTC") { humanized_duration(Time.zone.parse("2021-01-01"), Time.zone.now, compact: true) } %>. - <%= tag.img src: "/images/padoru.gif", width: 24, height: 24 %> -

    - <% end %> -

    Upgrading your account gives you exclusive benefits and helps support <%= Danbooru.config.canonical_app_name %>. Your support helps keep the site ad-free for everyone!

    @@ -52,16 +44,10 @@ Free - <% if Danbooru.config.is_promotion? %> - $20 - <% end %> <%= cents_to_usd(UserUpgrade.gold_price) %>
    One time fee
    - <% if Danbooru.config.is_promotion? %> - $40 - <% end %> <%= cents_to_usd(UserUpgrade.platinum_price) %>
    One time fee
    diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 3467e4dcc..b9525a381 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -528,12 +528,6 @@ module Danbooru def redis_url "redis://localhost:6379" end - - def is_promotion? - Time.use_zone("UTC") do - Time.zone.now < Time.zone.parse("2021-01-01") - end - end end EnvironmentConfiguration = Struct.new(:config) do diff --git a/public/images/blobgift.png b/public/images/blobgift.png deleted file mode 100644 index bfcb3aa64dea7e0688ae899c487813c737486e66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15830 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 diff --git a/public/images/kemogift.png b/public/images/kemogift.png deleted file mode 100644 index 300da66fb4e6961231b56b5a87d1ea307561b266..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22398 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 KKbLh*2~7Yb;jfec diff --git a/public/images/padoru.gif b/public/images/padoru.gif deleted file mode 100644 index 454968b169d5a200e2eaa7903d2640f9fa1439f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22445 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 F)&NxqEaktG3V{x@;S1{ zOP~L?4ZU0Y_|8c|Nfn0)4923ltWgKKre##Ro|?9W?^9G!#QOQyrT5%pI1pET{q^&$ z-z?uu4m-asY+H_?kgK-$G}osNLOds2{ETNDd}3{0UOxA``+aL;hBN^W9@YA3?eYZ; zF9WWIE!GY)GzzKC%^AD*6S=e8F5l?!f(g&n1}yl7l{A&dlvt- z*;(Jq#38^E3M{+6vfR|DMPGIm>|I@q*uumjZ0`~HJl;;r@igPwzhLcy|X)Zn(9^@NO-Zsa?RybrBiZOIx<)I zrv$s4TfC4@ovqZ+Poaoyzg_$MpqK!KLk6t{T0zI83butjJfR;d`SAA&v48$44c=-p z5A;9focwtwwWTz%SKM>1qOgGmWA@qx;R3!#0!JDrKH*I|@S`D9FaPLDr7(fj0@n^| z-7s0vTGi66{H(#y{%G9!4=beRZR4F+d0R(a|JCy@<74((Yynq){_FDi9IjlJZ1OtiF_{bb(<`>hZ$Z%{E)PA3ZTlQ4Kw8FU1xR8kv?f zeQ*88{n6*`IJPk{=Q=<8+ACQef8~Lh{`soa9&dzPb;Jbb=$-j#+B;dTK4jMU_?nv+ zQ$$=l8aLc*SSl56u_SZfB;60w{rd|O)Ia}?5LEl&ewcw_{_i`_gOZbz=O#++AMjjr#rg*bM1cUw>V5TRhQ!`Q$r71EK zqpY7#O>?gObkTA1@9NrbPa(VKcxUt^T%Ys!{{PKN z!P*yJe1BS8`g>2>o+)LEE?rpyve>%(U5Wg^5AFG3SM7x-PG1_NxqtQQ(-O%B#~#dC z9HVD!Y8u-0c*mQ{xcu<&=`UZtT>13#&l8sEtFLA)3tDRwJtsAcD+T~@w>-O%At-UXv%%ikus+a1V zdGo$yZ=JRE+NJyV<)0Q=ZoQ_}`7u3f>nxFOS2wrg7N$l?SzoPIMzv<7acQNVc_Q)b z1LGd&-w7pJh6gv*#HTK4I_xNrq;!16$zjuyQ8^ecLX8X@&zkObQLnh0q=f{){fB4$$s}ts2`kK)6yq7U5tmdQfug|%f zb}f51h)W%cIcl0z_};vtzx> zIg8ZXY@F|E{?|X1Zy%8IYNu5D`PD2MbwTlp)7B@OxE)=omuGL-XryTv$G0q9YXA4E zFVC-=o#%7E{4lTiGxPj=OJ@4GMXmn7GoZUt?0-q2&)$m*Ow5n(ycp=$KlgZV@1&m} z<}YmbYwBfeck?y5&NNTu_s-{?f+2xQM>JF)e?C4}u>Qv_OSMWqv&QnL(etdge%WXI zvED0E*i6Z0B6kXdf=0&DbsMiw(D?Fde`~}o>sxmZIM}}|`L)Z(&S1Ti*lG?JkL8!2 z6j^e;eH)@3>Nj0`$-RGh7F(uoh;dpKdghCWkzJdQzth%vZ)E084`dNM5_#q5lcOKI zFWY@Ps8j#Nq4i?=YQA~%*S%+OJv(Q^&8V71OV7SuRY z{i}|gdl;s^?49S){51``wP_mop`mo(BjX(pVI;GDvUw3i0;}d(vgzZ_H2H z|Lc3es)lqS2hEpny1riKXgc~ZV|qt?jzy6C+sF2MKiec<&k|l^!7TGrEbX(ckl25{ zcb}L9rK(u@L_-!{e!XZy*7C5`f~|s%0uySkpPH?^s>`sg@u1@`cD91To>fA}4X%1n8L&f2coZjl%)F^OS*_zY+Ek}BID$Be>fEe+AS7?yF|GBxE!nn_}hjsx6-& ze0<`^i(%oyZX0~tcJxg;*sz1)s(`QSAE)S6=GzbT%N=UAo=!mg9@=sryroZ5kYPYAT;D_4zZi^3o_+aq0YHnjA z&+AMcj)Y`&bam(x8ODmKG=slWi-TVLQ{^M_M zv+tXJgJY`Kq4jp1Q;X;RIG`a^xgvLh`KkT)E**`V`KUVl_n*(3xeqKo(j_H$+_mYW z5&uTZ=fp^eW{X}- zdNBLBkCM@Okw6I{7Dn^Ddp>HD9U0GmpD}M{s?OAfzm}Q(sg7g2mgJ>+=4A2=W%h&= ziL6-+KF@wh^s)KeeH7Gp*6-)h{%00H&lx4hv}#2Jw(ffG$b0MHw&i!_pWixje)@9` z&ua%lCr&gfwmLWS6z8$SnWru-c&F5}H9}j$uqDuCYnbu}X0yCI2bTHH{%GUPtrKOF zsqLsVNnz5eihceoLmqv2czBMitnZ8f(}`hUt-k%S-1FBzab6XVq@3rpruy0$4y;QU z!!)G(>&5*)HnJ}$Jo?Z6`LyW^kJ~T!clW)jR-M+rmrmFIrEXkh*vVM!o6GfJiPP1o zGkCqvxT$Itc6QBHQGT29M^T& zb{$ok>NVAEt=H0^#9blFLRJced)+Wr-*Epo+p~}F6}GZ1dGbhLa=I(m4XcDowujTd zpZ^rva5|Sa+=um!uTV?ipAwU$i?ljkZT@lS$n43g`jJJB@_!wD)+h>=u3@Q&*!7pE z+UvJv-6xfm2a*#CIzqGU{)vgG-C-XG?VKQUH6`?fLt zzFnL9@fR=O9qf4c<74s)t6R5jop@Rl^TvHg+>S#>cTbqM*sDi%>b519Gr8WrE!omv zT$VE9^EH9JuX;T;cKkfHU4iG|k??Fq-Mj1pJbp|p6}`*%h*Wf(F1S{lkym+ZE4Q-s z(bUT~55^?atqBV*^?N&Ye#|^E|H+Fiw)~V|@r$QwcBSX>ZixwH$Ij1reBoZRjLnYu zHy`m7&RKIvrA^_bTlQ0Zwpr`US8A>LxJTtfb3^Fr@XeK%)vAgt7lm{kd;MPd&vN_T z-EYl4G^eRN*sRXDSnkyV?&JP-Chq$i^5WChg}X4_@fV5=y|m(P#kEN7jjygs*M~1# zx`4%bdgSqLZrhO0>NEMy2VLTH%k5gJkyP;$;s3Yuljqg!(R`)S$#%q6`>aAj z^3&97k5dmEK7Et%`dyBY5SEKC{j=H)f5qOPJo`|@+=9~I|DSsua#C3~(aQK^<+myG z(sr0Rx~DfTJjl3fSCz&VQH!cK9Pa&ckEhMfPkhWP{am>xOen97wPs<|+Q9W;7RA3r z(pT}`_|9^#{U1wl4o{Fuh2w#(kJ*}barm@s*0i$||FHVKWNMA(j32+e8`PqruLn)I zcxTm(!q6A@zvk>q{bKEVQ7wCi_WWgs?GJ2xIr)vHedBab;UAHQs(70PmIOt-ei+kI z{7xw4FZ|QLgac5<*n4Jy_wNo#8dD+|WK{_=H9FSZXJR|=@AGnMO4Bs0f>Kc4&Z(CgjH z&iZaYHff*c)2{Zsb@u;uoOs8zBIo|T!*A~F{5&frTWQg+j;f=P`>vQY)F%}2>)r8U znw2(ndv8p%yz+=3C!m*(cwuxHdQZ_bE~3<#KP0T+;W= zo)ztW(YI((S(EbO6<54d4*_Q+?&ZQo4qP!u z4XQlWAb87e*7E0bRn^4qKQ3MFXA{%8CpYM_y#BG+dnJqe+e-GOSiRX*YV_jYLUsGS zW#Rr`zdUy>i?uX;sT)!DNZ_yO{mBagy?L6BZkJ%3s^urU@1)=^c^);7ss%?J+%M5L8?{?hamPpV{+mR&2QmnmRQ@>{n7q*UR${S_mgexLf)T}Ixn0#mdi9#kLfngm)y0bM~$7<8Xo>_ z|2JRb>Z(7#YHz>H-#@X6!%z0v?>ud<;2f>6;x&AE$)U^o7f%&_qd89@tleOKhtdA2 zyKhME5c=Dd9a?2}r=$A8-8c2GG`-sU6ZUFvzZ&%8qD<|}GAsRk?>ZkmNIw4M_Mh#K zw|=ktye0B?aB%NE^D{2Ct5@my#}>yvUGbrK^Sa+tTi>j$FMh(UZeN)Ev0he4(JfS@ zQC91X)4pBun)O=0gwLDnuQc0n;aB)UsT<|by#Ks4w`(5?{!RU z$U2)k*2a%HlNP0ft0tHUl|o2LGuR=`=t{kkeo?oU3> zBYf900(LykyY;?KaO&gZihb)lcr${}v++&r=<8lT`|a)Rv)ScqE}S~O^4(cPIj8yc zmFX+ORA0PY`ghxHLrMAGol*%36AxGhP29wqaD0*Iv$Of1LtGgJE&1PAeLvA}XBM;j zwfcE;wtGghW%q)+Za2SlTOMEfbNaa%-=vrST~{st|IS+eyLZdai>v?p!+q`b%b)tP zmURmwl!PXFGpI2xvUqmlgy5Qw2Q(Ys3m*H>sBd%P`@Ele`+t7B|9D;Rdc`hTrmHL0Er0F1 zj_0h8b2P(#&X(i}dzfJ$*taRYOy1ppp2AemiT*`*T!y^ZmNNCxdhTefgPj_PlS~-t{xNt$)8R3|qeV%Bm{MAEwXy<#wHx zx1T-r-qo)jv!@*t;)@pKx+&uq9l7V^`($z93e9uAzMqcI|CwyJ{de%5=cVr-U5&oa znKJKc!&(+qv9(8)PliOvmI#(Sv52rdaj=&C#1>Hjp1_FYjEsh_ugw{{il=v5?b;T@ zUTPS(P*!d+?g6=pg6Kj)-gt@aBud;9yI zxYqoLaLXT0|1Gz-RJ1O9tN;I!NX^UBQ6zg_pcS^Uc2|9AEJ{@Gz`lS{8> zci!E~d&z6^Z2y0TUvBZH6tnh)e>bT6CsJHEHS0-oZ)!$HhmV@KgkE@Z(B#}0rakc+ zV>3?`&(2qV@h<1v!$XZ%mT~TO@ivip=KFTb+gYj0t}aODJiP0(=FWFHr(WNP(G@p) zn)5UD=z`bM%j4=UKkuul&3gal%sc1rdrHeytAC51d)55@r$_a5@%a*K_rD2ux?OnR zMWnpPZQ*|(+qv_K*l&u>N#B~Is9ftZE8_AcpC=#Ire1#FmUVG*%4NoPLV5u`rc3rT z^fv!3d1hW4-&JB_eYr1UPT{eY3pi?9Z!qvv9@fxnH>1R?#Un_2=oc4}@c9&7Gq-ecdG1w5a``&C>h+#D-qp ze(uQI?N_!a6bH7SIr_GFa^U&DH-8y_<1D6ar*3< zbk@f$_g2NtnELjv^q<-7-%qaZeR}wvxmf1^iHk~dSGer_w#&Pkce-Xs?7wT4#^uG9 z8P%76oVlP}`Cfd#<RU$tb9N`+?X9*jwmRrg zZ{Ik5L6xlR+b>_g>OT>Q;jOr`$9?YMluazHdPlZdJUHZ9@^bSEi51gce8~+uU*vvT z@s^L~1>yRSt4eae`p#Yz_FKPlHPpLX)msUVbDttvTJ) zmB-}Dgr(D?bJUq{2HVKKOI11ZF~g+U`+e%>y`m=Ulb-gL|5Gc^t5Hrr_B~*2^ZTDX zC2P`i-^X%fJ`$Z(6OeR$L5TdqnAwlc95HlT)@E7rP2%}7b&2`5=SxzjWvz8AUw9xp z@3&%`RsQ~EVnS`7G&VF^bKjlv$n)8w!gH;M=k%q|oI5u;r(}xL!V6pY*D?L@a*(>> zDq-JzeVX&p*r}X}4lat1g{!ky7A8EL#qKM=Pi^CBrA5;5zqcnJNawTrvM8T_Yi?w< zUDW@@#b5i(&u8Agw$rV8*~3oEiziE0EWLkz zh3(;$8@!qerP6w~>}G5iI@;EFXfDsT`#TM?xB_`Ta$RHWxT{nD{RR8aUybjBbiy^S zdWF0W%}ShW&{N=hI;Jq>m6%FhnAD1=U83O&V!29KSD&oROuK7$>~!h5IhM}**7h!3 zxNvm{>)NQzvm6c_Q<=?iebdBg&slQyzQ;8F_|V9`KTU3c>)wjlrpKj4{ak-XJeN)1d)J}=rK-NIN+v^P*v#3I zUhNiH64$TYkuY26yZp0tW4K z%Vt03-0ls$^7BcA>weoqTUHtb>l8jYAvi1bEyJ^gPr9=u&gOrOx~lV7!qMQ`VOPZ@ z2jyw0SFRn2(%Am=L_pfQ(zjiX3s-Sihde1Qs(H5F{^NrA`Tu!8a+qZ~Yp2bcm@#GX zMN7Wx&hsWN=BZj9`Mh+~t#6ltn7cFe{iu=!sNL2!fdXnZ|Ale9AskC7mvI( zebT{&R}O3KIX}0^BXj#LxW{m0U12VzqUM zUwuj0E{7>wlL}cyj_Y96}tIwD-@99VNc`1i?m9B~2-BuGVT_|I0 zZhrjB7ZasJx3*?~Y-Zqf?sd%YGW>e0A2w`@^PN95(jRB~9eZveJpG07@*Q{O7hK=p`0;c9$A$KP zMZejUPO~tp2|k}xA#&cXWkJr)pJnf=Zl1om>Fv~7QasOT%K6-hM~Xa zr~c13$L&2gMjh*T@MQ6xU!kun&g<8=lvF7f)+o+WKXBLlo@e>) zZH-f(zxK0tewF;{U1k?MqJ%OG7oQNS-~HkKC8^3ni&6{D&Ml5UK5ImkoaRVXHRtUL zW(-d3EuI>9?D#q1)suYL*Tiq1w)OS3$@$rbT?NhNx;^fAd3>Mq%L~RH2b5-~cAitQ z4Pahm82HBGt0RZFJ4@`uwV|P9Zx8Lc)*UDI#JbC(KUFNzA6FA!*Z=TcT zHRX|1`7-kjx7IxD|986j$D!^2`|NIaA3J$OFXDlKaPU&cYblk7`}a@WzVGexm+$Hf z3wdUE&b#;5*X~c_{}U4*8kcz8uGxJ}!tJqV-B;WFXQJhQ)JpYwFKsYXjKBSVdCiAz z`A08ydTM=DW4_(d%N+aX(ChkH=J&qpcgC{08lHX5%e`Z6m$K&5*Rv9WnhvPnQdhb% zqpdAOGw@30vn^-l6r0RZR$BIsMb)W;L(psq(>t&4g?o%n9lXTB8`{RkF7f8QDwp&U z(ncPpEf2Z8v|9`(k{S)aKJROlHf}B$-4<50Y#I-naV$_7^jpb)PzRXT;c>C(4 zUuT;9wY#~N@3f|cuFYkW+#)q6|L>x7;b>8Rg+)%bdY9+DeYQLPzexR$_Wf=0r#3yj zrZmZgy|ch>){}F(**}lg|2{ouX5Y>4N7sMay1#zP1=lAzZ3cZ}w$mM=XFB!W|JnTi z?15F{g6`cj6@Ko1e{g2!`p;jP;~$*);i<$P`!g@D_;&P;%CE0iwkN2%b#%_Y9932< zvbE{pA|b)7P2GhOEb~1U`nlY_y5i!3g{4kXE>dbeD!gl28YYOjALFo-lSAhUh-6X9o(YEmY$*gR)`mRoE^3T>G|t?wV^pcjYPymmZ&|?A886P*CZ)-cOF~mdNnO zz4n`0pa^$B`oLr(Q1*QMR0%OmE`($C<{<=wDqu6>YC z!-UU1E{o@^>N5(me{FE=$Gr7D{awGGg>O7Bf{30eN2~P0-(fsXHv7b_OIvQ8i1V}kr;(|-AUyC_D1&~Rs+{@FrmW-BljA3N|8RG(h!&hZeA7qAR$t{*}w-Dl4z1TE?1eNrOeZ^Odh&(PeStHmp5tgrC=X=-rL@hwW3 z(S{-?-L5R}XIwwM@!MCKyqdB{0XuJmMKuhmUnFPl!?K6k2n)!Lw*)54WJWu*HeXy_53a{+gb1n^BUdQirvsDI#F;ty%KpnfgEV?f1WG+^Q3f`{$NZ#cX!jS#LYvEcBjCIi$=2=g;$9v&w^RmwY%&#U~1>Ctpq#>Q2CpF=)!wk$Pp${y4peo|(8mP_=`gRhFFe*4VV=Oa}A*ZHJv=7h7`4;(xD;zI6@=y*3qB_)aF%E&;jSlmFGSvWhp#xS&=lW zBSGoZR1FzMWw$|=hp@0h$cGuK z^R|meP2RR+$_g{}>N!?-S(a>QInr}^L6@`G+l$kc_z#Om9CggfxgwOk)oXWd0?!o{ zHL-)N57xN)ZaQ`Mi4KpwXUPN4CFah0FOq)kyKYePTU#U3eg~&Z)N|J#5_{CVx5{|1 zb#Sf}&f<10ka*_U)TSa?kr`v9%|UI=umZMrM$$(Xr^w( zHSQ`0-No-#zr6qaudvb+m&_dv9=c+xd0G4Hm~<3X7yW#0qj1$^(<$GxdcJBB3)|Wi zv$!s)tjj!@u&ZKueq$a}7mJPKEZZ%?3(hr!L_VGx@p^jZ=cFXo$e3B$*%S68NO{%m z@M`BX{=Kl@VBf3moa*cKONw9H?ueQw#mL+1&~@ZW#u3Rm5jCqi8V$|~Evg7&o6_c; z>@nHgZl6SmvBmRdOa7p&DE6kHM;>1#U#;x0-gd{!YVyLk^|6M=JEtyxK44z*0*no2->iq?(^(fS{&}L83L70%{&(A_UgGr%zgj*dilVT+~wC+ zYhC+%;#pCLhvVz7U%$V<|NgDds^*vttSwI^65NZD_1@+7GgZZg9Lo%9j7jO-u5*3n zv-7*6lv)xxvpGdBNhvK=$fot)*Jma!N7`&qUKK8U$< zZ2RI4@vnS~!=A6L-QMw9#_P$Xa!-b>uOxb_db)C%4)`8P-O0}$5ptnOGjpefhRL+e zGOJ}&wg}elwf!;Ae{W!Xkz4yLf9I2qe^>VA7ZD1{* z3#(wx3j?N&5p#SWuQ}>w>T`oZHZF`}nw#>L#`z3%5Dv zze`mnO_4FQpQc$99`O95!D->ZX*%Lvmu8+&7fVgPF0(gR_0_`EZPOCBOiAB!$8F6w zrrkqU( zx(j=3-z@c|(t<0Q-QVs1|1awECDr(uVT% z{^40=cCR%~YtkliMDJ>EO0sp%ENXJg%u?idd`tY9=*s(i8|{oeEp?LC9R0lKbed#* z%-a~3jzt}AiydW-8(+J1+|@SZEJIzZU|Mj`| z?X$}^`P?fPESRRal}T^buj5RAU)N;XZs+trIR0>twU1W-yQ!(K%No(b?G9@b%{3%D zI~${llUx_{eG*!KD%vR3Wx=X~@Kp!34_Vs9=P!4jV%3)WR`uP>RY85?&yHDg%$7QB z+`08`mO-Gu(~~nb=k6Az1lpWW&bt(-FMsTWc4Vj3ZLW@psvP@VNA|a|vMl@jm3gM$ z{ja~y@6jA(4yLs9^rwG*e!h~mwYb$Ok?q01-|yo;aqFLESjZ{bYLG8k{dv9N)Jp9j z?t{J)&Obl#^fsgO&X9#B=8jkMo_=_{|M-_(?Gc}++N)M?ahkHt{`8!D!;`tnUH z=uTYjy>jd8jK7_)4xhbpOElu&S+h&W4zF4mwN~}H>~X_Dk+c5alCtYRc4}OUTC>&4 z%uKDgxLEbU=9eW^EYGeUQZ0#OTVbirac1duqsQMLd#QfPjSO3V(`(@cH?65IDkf5; z=jP9UaOZ^Np_v^Y-}nda-9CHk(f?j1X3n8e88C-w*!3)_k`KQ7%7)n#+ zm{(*My?uUrc}8(*Qls?kRV`I7pDb3ftG@iHMQ?>tyJ88OxqS3|8-x6^t!2y;6e`)I zo^D>zJwfeQ!QQV8SwdG;jupJS!*TfVVJnL*UQrGg9vOWX78agkR~yC7&Mqk>HA$mO zLRxyVar!v{*Nf$!fBspwec#u$Q=bMnMD#JfTmJH~!AzD}d?wQat=O&`U$A#DoBrg@ zcBjLeZtA?gwsz_6-LoGb?>90wJsR`Q-SWHF3EMyOIbV0PS7dI|a7%LXy_c=h7_n&C zjyRnHUI8Z7^B=no$t+amtlimhJW5xJ@x0E#hNOa76>pQL%Sfzb%Hr*7xKVksQ~t;T zK`E!wR}y-w4lOd+^4gQ@M^HlUFSha(+m73Qy)rp&XVAjctGh$AT02&B1gs5v`s3r{ zITnRZT2sA((zZ!@#;gocTeG#^)Y`gwOX$who!|5l8C+dm=BzsPMLk=(W5Mq9j&J7} zYxEsAPBK{6n(Hc5p((;;xh-es%x3oQM#jeOnP*q8yk92q^Vjo$%iB0ocYayiYawN^ z%%o7G@~r6nPo`V??q#p_^S=CX3Afe$*0Wnfb{(p5Sf9DF;aNb3;fn5=E3a9KH+?Ri z@akCf*TXEUXr~KS4V+4^XJW5=kMp2cF3T|H6>na>aP&J>A&AZ z-~V;(!`}C`?^AW>c&j?Rp1sfI+UA$iF+6T-e$0z)sg0@la+yilL4YOa)|Sq{)%$!+ z{S?jj^5x$bEXnFoSy=mWAB%-ep=Y0BjDVb~?#KHgAC&|$9=-3E37NHM*HHoSDpgiP zud7Ep1+_YLx?-n>TyWg*Pjo|~>C1c89$#|~vdQj<=zPrPcUVQ#+vv!p-k1BjEt56Q zIfa`a*%;Z$5*B*pDf9ij!rOMgOa2@wUsUy1bn(%fx%=MV&+Q3J2nkhpc@kqc-(Jc! zaOuSimwoTA*WS0U`|#l4W9#yFJ+q37BTpnnN++!IiPEv%$hp@HT2-iYzSY$Ub&or-Ch7d*@UZS+m<5qRUv?lvnI- zy&l){RON54;v|)lVAErQ7po6-l^*COs@phf0 z(_U#ZiFND6AYml|i4L3NDvEIv9v=(;&+>QszBwW5=DO|d>+ZjFt#IDCZ@MX$q&=70 z7VXSyTEOd}5-(x3>MGaOtf>zlCicnM9)0u1(k$nWPxQWija_y=7tZwb?Bxl|zQ(ww z>ZD-x``LAmYzqEe+8D-|a(!JaTf+Z;f2(v^_f4P5Fi&;)qSZ?6**;>GiZN_vN z-RgrUnjX&h`~gYZO)9M*yLH79jjKZu3D)j@p$sd zHEC65N>fiN%ySo#D_prpNI{j)e^#cp_Z?>6?zfDEJ)r|Te8-Cd(Z8q;U6Ex`l|^Wm{^>+@j_zjEakH6{L(^#f{L5=bzPY} zsrGyP!%GwNl&$?oyv87b$(n&87Zt+Hqvf!gTwj^EMJbfxd*otY-OoEor z3*LKZ<$f+9^~vkI3@m+F7FM1f<*Pm4_wQTrtF=B*Nl$Z)y7-+RGPicrEm@T%5Zd{;D&}AK-TYU2 zo4AWrWB%W~aLMgKg%azj`As`5uQLd*SYfQB#`E05{F_LiNb-`dT^&`*?n|Epu4=V7 zIhpU+j-T55yDNVgt}0@FmSq3db;_ZMlYR)5zq@+j>J7$MQXQ*>ciHIiiR(Ee?embm z+K?{#wAeQHn(LDTtHX5~J-~D|>s`0eM&aW-{zp$=)#!$OXTK@Zp^~=Cs(zbuFF!Y$BX`!&>S5q2t(PlKyxJ`P_?rJ8sak(~FW(xcGpX&NjZ-?G2luVpUM$MI ztb)mSE5B*2k6%wr(iYp>C*v0`P*^k3VEW}7kN$-!ewcsB{mSH2qw0Cuy1RMz>=st% z_pteE`PXIVt9x^OXO~Xhb*xS;wY@~D_>X3e9lO+H*UKfm_1){5?;q5kS|}Fdm2ri4 zVs`GoY4U4xj7>ky+W)~T+ey@h`>5oZo9h#+-ldh~Olv$Sv@)dbV%w9OHzTwIPsJ^q zcv5i6sbA`Ezu&a4`BV7y{Z99=H*cn-N;=3*`0aZl||1K|K5!{k>7c$Do9Vx z>d%7MuMg%gp6f7UMW|>2W5z1c1q)5C2$)_r*EbK76i$44sbik@#hMq_o_d)6JoPX? z>DvBQw?yN`$^M%X|7v>}t(rfVd3mcuyuDh0$eeuD8MAxUoxcdL%G$RpnKd_g-GN`< z_xL;AUAOG9`jh(B(s#kn)r|ISo#?yUAhq+7=As^fG~Pfp&4ZV^c8D%$ocaA-Y@gAi zXP5V_Yh9Oh>q=v~!RF*0503}5J~R&Xn`h%D;u^7g#hF{7lQL)WUoXs>b@bv9j?j(Y z(yQ-3tDhQsVy|_0-q*vSC+u!&`pE~bfBkloyK0@Y>e2dB`FqcIh`YBWP6~W1Q*`l@ zXzMQFMcPbzyrQP*eJC*9vO?k7rYABLnu&Sumv?qAPO!2pd}Pu-&(dLK$g$5)R(;ud z!O434>buWrb*-H9^o9K&)4+&B{6;>0K7}6+ z*!j)3^IjU18}?>jPoesPD&FN8@$0;OHzwy!%F4L+nw?M9OVPw|C~{?gYEV6 zdvk?eHfM?cx#GY7$M)xHZvWl4DL-BQ_{IvMRYE&mv{cveTE%S%}R*yOT+6$t;n&E!T|pJeO{{s`NVk z7u&6Nvq#TwalBSMnif&IP+(J4uJzry+aFZto{=^?4O9>iaBatX7~Mw$N3Q@=mw z8~!g{*srzr?YGaa*N-qTI0@yKF`qFyyPl`KIC%N2`k8!>>t4R8Ougnhck4yT9>rCy zS%z_&-<(yd|F5(+x9~ha^MohdQh_cuYW;7PRXo#}Ykto+@$K0!Yo(Ud7U<5*5nmeA z>&$+x=(wzHN!hlCQKk9wd}lK_J&FE!>1|5aBo3E+LQm zQ(w%IT;O`v^x;XB^A3OZxgXg5*VX>dr86^YXMbso|7E-1&N5-&SJ_1q3zIIjuy9@K zxgGsMukz8Pu0-uWFP((krhSqrUU=z1(8}HAY{J|L{LPbbi}kmtQsx!ViA#o7ewq&ee5#M-tA} zpZKX)y1@e7JX|vVYCIm79gHbjulE-F3{OcY~G7;XgIMH;C9w zXQ|${;pfpsk1m;QYo0{xy>EtIsZeDY}TQ=6i|$=91#$+4;|pRliHh zc%1#>MSQ#b7D0E-Io?@}9V(3rrzbZY$!^QmV`K97(Y|QXrC7F(=hK$upRz+w@0_>& zgh7Md3Pu6@__ljbU+lUOyX%A8;k)J1H}~$Y3|a83k>_{!$1jaH|HrMLx3~P!8{xcH zU;cev+|FlY{Boj{?b+Ll4^NEuzjWY=(y`-K+AsbX9_hTiH?=-~8l#Hl=Y@qmZN34g z*w3Gyu=`=yp0)Xl(?74yc)wTJey*G7q6yCya$C*MxcSRu;?JaydY%>jI?;lK#~wVr z#JQLwV3liiVDN@xH9CAhYJ-nV%MM|yi#o;di}7ov{>AUkSM=S_N3q8E`07e-t}b75 zd%OB7|0OP;8<-xXGnTknufG4V-f8FJFx$!Nedkm}<`vC9{_nk@&$$CvRPvsL+?~42 zH=>KB;F0s3Lyy?Y_AS|yy|jx%f?L8x_L0&ftr=S$d|#`4^{)GQTYI8ok~^wTXx*CUA4YqgxrnT4VUt_sV}e|>gxbhYwb{VMUd z+wZpLMt64y?O0|KQS;0FQGG#}=tj>^3#R9%+qnfePf8Mq)acu}S)w-L(T;UVt29`9 z_vV-X5&n6D_5STox6fN|uPX@q9kk^UPl2JP)^7U^w|7^6zI*E7U8VQCpEp@$9Bu!) zY(evN^G{a<7X1i`xZrLtR(16A|1G*7;!joCx$`+NzUtQfjV--hT^szK8)qT^e zXWLYr{BE<(u2`d8uyTG(wC{F#hFy7D53)Q3p4Oh>zw%f<;Qu?n6#L&dPlY`>{#5e8 z%yuJ4;IPq2cJLSDn8jde@T#}6|UYbJ0e(|1?dO%s$)eN?ml`sm5L`kKl6X3Q(+EKsfb;JEdB zevZs7Zid-}Uw|Lb&j56~fxODE|BPl5ALn_U TI(LMDfq}u()z4*}Q$iB}78J?! From ecd29c1a66f72a3dfccd8eff42cca9742db77a29 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 04:24:24 -0600 Subject: [PATCH 117/132] user upgrades: allow using promo codes during checkout. Allow promo codes to be used during checkout if a secret promo=true url param is passed. Allows promo codes to be offered without having the promo code option always appear even when there aren't any active promos. --- app/controllers/user_upgrades_controller.rb | 3 ++- app/models/user_upgrade.rb | 3 ++- app/views/user_upgrades/new.html.erb | 6 +++--- test/functional/user_upgrades_controller_test.rb | 12 ++++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index e0e86e566..0a25e49fc 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -4,7 +4,8 @@ class UserUpgradesController < ApplicationController def create @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) @country = params[:country] || CurrentUser.country || "US" - @checkout = @user_upgrade.create_checkout!(country: @country) + @allow_promotion_codes = params[:promo].to_s.truthy? + @checkout = @user_upgrade.create_checkout!(country: @country, allow_promotion_codes: @allow_promotion_codes) respond_with(@user_upgrade) end diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index 48083c6d4..de81fa214 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -148,7 +148,7 @@ class UserUpgrade < ApplicationRecord end concerning :StripeMethods do - def create_checkout!(country: "US") + def create_checkout!(country: "US", allow_promotion_codes: false) methods = payment_method_types(country) currency = preferred_currency(country) price_id = upgrade_price_id(currency) @@ -160,6 +160,7 @@ class UserUpgrade < ApplicationRecord client_reference_id: "user_upgrade_#{id}", customer_email: purchaser.email_address&.address, payment_method_types: methods, + allow_promotion_codes: allow_promotion_codes, line_items: [{ price: price_id, quantity: 1, diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 8766dfc83..3bdb26876 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -102,12 +102,12 @@ <%= link_to "Get #{Danbooru.config.canonical_app_name} Platinum", login_path(url: new_user_upgrade_path), class: "login-button" %> <% elsif @recipient.level == User::Levels::MEMBER %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country]), remote: true, disable_with: "Redirecting..." %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "platinum", country: params[:country]), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country], promo: params[:promo]), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "platinum", country: params[:country], promo: params[:promo]), remote: true, disable_with: "Redirecting..." %> <% elsif @recipient.level == User::Levels::GOLD %> <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold_to_platinum", country: params[:country]), remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Platinum", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold_to_platinum", country: params[:country], promo: params[:promo]), remote: true, disable_with: "Redirecting..." %> <% else %> <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", nil, disabled: true %> diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index 8075eb7e0..d396c0d1c 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -38,6 +38,18 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + should "render for the country param" do + get new_user_upgrade_path(country: "DE") + + assert_response :success + end + + should "render for the promo param" do + get new_user_upgrade_path(promo: "true") + + assert_response :success + end + should "render for an anonymous user" do get new_user_upgrade_path From 0b2f9fafa8b725d23f47f7cb761302f684a64362 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 18:50:03 -0600 Subject: [PATCH 118/132] users: refactor limit methods. * Refactor various user limit methods to class methods from instance methods so they can be used outside the context of a single user. * Remove the Danbooru.config.base_tag_query_limit option. --- app/models/user.rb | 137 ++++++++++++++++----------- app/views/user_upgrades/new.html.erb | 6 +- config/danbooru_default_config.rb | 5 - 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 01730294c..11bd171fc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -360,15 +360,84 @@ class User < ApplicationRecord end end - module LimitMethods - extend Memoist + concerning :LimitMethods do + class_methods do + def statement_timeout(level) + if Rails.env.development? + 60_000 + elsif level >= User::Levels::PLATINUM + 9_000 + elsif level == User::Levels::GOLD + 6_000 + else + 3_000 + end + end + + def tag_query_limit(level) + if level >= User::Levels::PLATINUM + 12 + elsif level == User::Levels::GOLD + 6 + else + 2 + end + end + + def favorite_limit(level) + if level >= User::Levels::PLATINUM + Float::INFINITY + elsif level == User::Levels::GOLD + 20_000 + else + 10_000 + end + end + + def favorite_group_limit(level) + if level >= User::Levels::PLATINUM + 10 + elsif level == User::Levels::GOLD + 5 + else + 3 + end + end + + def max_saved_searches(level) + if level >= User::Levels::PLATINUM + 1_000 + else + 250 + end + end + + # regen this amount per second + def api_regen_multiplier(level) + if level >= User::Levels::PLATINUM + 4 + elsif level == User::Levels::GOLD + 2 + else + 1 + end + end + + # can make this many api calls at once before being bound by + # api_regen_multiplier refilling your pool + def api_burst_limit(level) + if level >= User::Levels::PLATINUM + 60 + elsif level == User::Levels::GOLD + 30 + else + 10 + end + end + end def max_saved_searches - if is_platinum? - 1_000 - else - 250 - end + User.max_saved_searches(level) end def is_comment_limited? @@ -404,56 +473,23 @@ class User < ApplicationRecord end def tag_query_limit - if is_platinum? - Danbooru.config.base_tag_query_limit * 2 - elsif is_gold? - Danbooru.config.base_tag_query_limit - else - 2 - end + User.tag_query_limit(level) end def favorite_limit - if is_platinum? - Float::INFINITY - elsif is_gold? - 20_000 - else - 10_000 - end + User.favorite_limit(level) end def favorite_group_limit - if is_platinum? - 10 - elsif is_gold? - 5 - else - 3 - end + User.favorite_group_limit(level) end def api_regen_multiplier - # regen this amount per second - if is_platinum? - 4 - elsif is_gold? - 2 - else - 1 - end + User.api_regen_multiplier(level) end def api_burst_limit - # can make this many api calls at once before being bound by - # api_regen_multiplier refilling your pool - if is_platinum? - 60 - elsif is_gold? - 30 - else - 10 - end + User.api_burst_limit(level) end def remaining_api_limit @@ -461,15 +497,7 @@ class User < ApplicationRecord end def statement_timeout - if Rails.env.development? - 60_000 - elsif is_platinum? - 9_000 - elsif is_gold? - 6_000 - else - 3_000 - end + User.statement_timeout(level) end end @@ -610,7 +638,6 @@ class User < ApplicationRecord include LevelMethods include EmailMethods include ForumMethods - include LimitMethods include ApiMethods include CountMethods extend SearchMethods diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 3bdb26876..7e25103b7 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -54,9 +54,9 @@ Tag Limit - 2 - <%= Danbooru.config.base_tag_query_limit %> - <%= Danbooru.config.base_tag_query_limit*2 %> + <%= User.tag_query_limit(User::Levels::MEMBER) %> + <%= User.tag_query_limit(User::Levels::GOLD) %> + <%= User.tag_query_limit(User::Levels::PLATINUM) %> See Hidden Tags diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index b9525a381..ed77e0ae9 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -107,11 +107,6 @@ module Danbooru 2 end - # Users cannot search for more than X regular tags at a time. - def base_tag_query_limit - 6 - end - # After this many pages, the paginator will switch to sequential mode. def max_numbered_pages 1_000 From 890c793d9b3ad453652f3d165cb2dd0ce0ea589b Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 19:33:14 -0600 Subject: [PATCH 119/132] Fix #4644: Give unlimited searches for builders Give Builders unlimited searches, favgroups, and saved searches. --- app/models/user.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 11bd171fc..9b47b01d6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -375,7 +375,9 @@ class User < ApplicationRecord end def tag_query_limit(level) - if level >= User::Levels::PLATINUM + if level >= User::Levels::BUILDER + Float::INFINITY + elsif level == User::Levels::PLATINUM 12 elsif level == User::Levels::GOLD 6 @@ -395,7 +397,9 @@ class User < ApplicationRecord end def favorite_group_limit(level) - if level >= User::Levels::PLATINUM + if level >= User::Levels::BUILDER + Float::INFINITY + elsif level == User::Levels::PLATINUM 10 elsif level == User::Levels::GOLD 5 @@ -405,7 +409,9 @@ class User < ApplicationRecord end def max_saved_searches(level) - if level >= User::Levels::PLATINUM + if level >= User::Levels::BUILDER + Float::INFINITY + elsif level == User::Levels::PLATINUM 1_000 else 250 From 36f95891bd3926d3c2ee696f7c325ceba3585263 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 19:47:51 -0600 Subject: [PATCH 120/132] search: let wildcard searches match up to 100 tags. Let searching for things like *_legwear match up to 100 tags. Previously the limit was 25. --- app/logical/post_query_builder.rb | 9 ++++++--- app/logical/post_sets/post.rb | 9 ++++++--- app/models/tag.rb | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/logical/post_query_builder.rb b/app/logical/post_query_builder.rb index a694e7f75..5a5b11fed 100644 --- a/app/logical/post_query_builder.rb +++ b/app/logical/post_query_builder.rb @@ -3,6 +3,9 @@ require "strscan" class PostQueryBuilder extend Memoist + # How many tags a `blah*` search should match. + MAX_WILDCARD_TAGS = 100 + COUNT_METATAGS = %w[ comment_count deleted_comment_count active_comment_count note_count deleted_note_count active_note_count @@ -77,9 +80,9 @@ class PostQueryBuilder optional_tags = optional_tags.map(&:name) required_tags = required_tags.map(&:name) - negated_tags += negated_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } - optional_tags += optional_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } - optional_tags += required_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) } + negated_tags += negated_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) } + optional_tags += optional_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) } + optional_tags += required_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) } tsquery << "!(#{negated_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if negated_tags.present? tsquery << "(#{optional_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if optional_tags.present? diff --git a/app/logical/post_sets/post.rb b/app/logical/post_sets/post.rb index ff48e9c4c..69f1fcf5c 100644 --- a/app/logical/post_sets/post.rb +++ b/app/logical/post_sets/post.rb @@ -187,16 +187,19 @@ module PostSets RelatedTagCalculator.frequent_tags_for_post_array(posts).take(MAX_SIDEBAR_TAGS) end + # Wildcard searches can show up to 100 tags in the sidebar, not 25, + # because that's how many tags the search itself will use. def wildcard_tags - Tag.wildcard_matches(tag_string) + Tag.wildcard_matches(tag_string).limit(PostQueryBuilder::MAX_WILDCARD_TAGS).pluck(:name) end def saved_search_tags - ["search:all"] + SavedSearch.labels_for(CurrentUser.user.id).map {|x| "search:#{x}"} + searches = ["search:all"] + SavedSearch.labels_for(CurrentUser.user.id).map {|x| "search:#{x}"} + searches.take(MAX_SIDEBAR_TAGS) end def tag_set_presenter - @tag_set_presenter ||= TagSetPresenter.new(related_tags.take(MAX_SIDEBAR_TAGS)) + @tag_set_presenter ||= TagSetPresenter.new(related_tags) end def tag_list_html(**options) diff --git a/app/models/tag.rb b/app/models/tag.rb index c83d4e9b1..6106b774e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -249,8 +249,8 @@ class Tag < ApplicationRecord name_matches(name).or(alias_matches(name)) end - def wildcard_matches(tag, limit: 25) - nonempty.name_matches(tag).order(post_count: :desc, name: :asc).limit(limit).pluck(:name) + def wildcard_matches(tag) + nonempty.name_matches(tag).order(post_count: :desc, name: :asc) end def abbreviation_matches(abbrev) From 48676789f02af6f2abddcdd701c3d8507572fc13 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 20:02:01 -0600 Subject: [PATCH 121/132] robots.txt: fix hardcoded paths. --- app/views/robots/index.text.erb | 42 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/app/views/robots/index.text.erb b/app/views/robots/index.text.erb index 57244f162..ca2007b76 100644 --- a/app/views/robots/index.text.erb +++ b/app/views/robots/index.text.erb @@ -6,25 +6,29 @@ Allow: /$ Disallow: /*.atom Disallow: /*.json -Allow: /artists -Allow: /artist_commentaries -Allow: /comments -Allow: /explore -Allow: /favorite_groups -Allow: /forum_posts -Allow: /forum_topics -Allow: /iqdb_queries -Allow: /login -Allow: /notes -Allow: /pools -Allow: /posts -Allow: /sessions -Allow: /static -Allow: /tags -Allow: /uploads -Allow: /user_upgrade -Allow: /users -Allow: /wiki_pages +Allow: <%= artists_path %> +Allow: <%= artist_commentaries_path %> +Allow: <%= comments_path %> +Allow: <%= popular_explore_posts_path %> +Allow: <%= curated_explore_posts_path %> +Allow: <%= viewed_explore_posts_path %> +Allow: <%= searches_explore_posts_path %> +Allow: <%= missed_searches_explore_posts_path %> +Allow: <%= favorite_groups_path %> +Allow: <%= forum_posts_path %> +Allow: <%= forum_topics_path %> +Allow: <%= iqdb_queries_path %> +Allow: <%= login_path %> +Allow: <%= notes_path %> +Allow: <%= pools_path %> +Allow: <%= posts_path %> +Allow: <%= new_session_path %> +Allow: <%= sign_out_session_path %> +Allow: <%= tags_path %> +Allow: <%= uploads_path %> +Allow: <%= user_upgrades_path %> +Allow: <%= users_path %> +Allow: <%= wiki_pages_path %> <%# Legacy redirects %> Allow: /artist From 014199ec2b7c2fd7dea05a72a743bbfb6b35b83b Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 21:23:03 -0600 Subject: [PATCH 122/132] user upgrades: handle the refunded status on show page. --- .../user_upgrades/_stripe_links.html.erb | 9 +++++++++ app/views/user_upgrades/show.html.erb | 19 +++++++++++-------- .../user_upgrades_controller_test.rb | 9 +++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 app/views/user_upgrades/_stripe_links.html.erb diff --git a/app/views/user_upgrades/_stripe_links.html.erb b/app/views/user_upgrades/_stripe_links.html.erb new file mode 100644 index 000000000..7aa57743d --- /dev/null +++ b/app/views/user_upgrades/_stripe_links.html.erb @@ -0,0 +1,9 @@ +<%# user_upgrade %> + +<% if policy(user_upgrade).receipt? %> + <%= link_to "View Receipt", receipt_user_upgrade_path(user_upgrade), target: "_blank" %> +<% end %> + +<% if policy(user_upgrade).payment? %> + | <%= link_to "View Payment", payment_user_upgrade_path(user_upgrade), target: "_blank" %> +<% end %> diff --git a/app/views/user_upgrades/show.html.erb b/app/views/user_upgrades/show.html.erb index 1b342ba96..a81978fc5 100644 --- a/app/views/user_upgrades/show.html.erb +++ b/app/views/user_upgrades/show.html.erb @@ -9,12 +9,15 @@
    • Purchased - <%= time_ago_in_words_tagged @user_upgrade.updated_at %> by <%= link_to_user @user_upgrade.purchaser %> <% if @user_upgrade.is_gift? %> for <%= link_to_user @user_upgrade.recipient %> <% end %>
    • +
    • + Updated + <%= time_ago_in_words_tagged @user_upgrade.updated_at %> +
    • Upgrade Type <%= @user_upgrade.upgrade_type.humanize %> @@ -26,7 +29,7 @@

    - <% if @user_upgrade.status == "complete" %> + <% if @user_upgrade.complete? %> <% if @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.recipient %>

    <%= link_to_user @user_upgrade.purchaser %> has upgraded your account to <%= @user_upgrade.level_string %>. Enjoy your new account!

    <% elsif @user_upgrade.is_gift? && CurrentUser.user == @user_upgrade.purchaser %> @@ -35,13 +38,13 @@

    You are now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site! A receipt has been sent to your email.

    <% end %> - <% if policy(@user_upgrade).receipt? %> - <%= link_to "View Receipt", receipt_user_upgrade_path(@user_upgrade), target: "_blank" %> - <% end %> + <%= render "stripe_links", user_upgrade: @user_upgrade %> + <% elsif @user_upgrade.refunded? %> +

    This purchase has been refunded. A receipt has been sent to your email. It can take up to + 5-10 days for the refund to appear on your credit card or bank statement. If it takes longer, + please contact your bank for assistance.

    - <% if policy(@user_upgrade).payment? %> - | <%= link_to "View Payment", payment_user_upgrade_path(@user_upgrade), target: "_blank" %> - <% end %> + <%= render "stripe_links", user_upgrade: @user_upgrade %> <% else %> <%= content_for :html_header do %> diff --git a/test/functional/user_upgrades_controller_test.rb b/test/functional/user_upgrades_controller_test.rb index d396c0d1c..9dc90da51 100644 --- a/test/functional/user_upgrades_controller_test.rb +++ b/test/functional/user_upgrades_controller_test.rb @@ -123,6 +123,15 @@ class UserUpgradesControllerTest < ActionDispatch::IntegrationTest end end + context "for a refunded upgrade" do + should "render" do + @user_upgrade = create(:self_gold_upgrade, status: "refunded") + get_auth user_upgrade_path(@user_upgrade), @user_upgrade.purchaser + + assert_response :success + end + end + context "for a pending upgrade" do should "render" do @user_upgrade = create(:self_gold_upgrade, status: "pending") From 1aabc0aae04a9bb6af62768a30e3346885db5211 Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 22:37:44 -0600 Subject: [PATCH 123/132] emails: fix invalid email address deletion script. Fix script to delete all invalid email addresses. In production there were ~4000 users with invalid email addresses because we used to not do any validation of emails during signup. --- script/fixes/067_delete_invalid_email_addresses.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/fixes/067_delete_invalid_email_addresses.rb b/script/fixes/067_delete_invalid_email_addresses.rb index 00b96902a..349ce24cd 100755 --- a/script/fixes/067_delete_invalid_email_addresses.rb +++ b/script/fixes/067_delete_invalid_email_addresses.rb @@ -3,5 +3,5 @@ require_relative "../../config/environment" EmailAddress.transaction do - EmailAddress.where("address !~ ? AND address !~ ?", "@", "\\.").count + EmailAddress.valid(false).destroy_all end From dbe2eeb00d812b69343b37288719e57d068f7f9b Mon Sep 17 00:00:00 2001 From: evazion Date: Fri, 1 Jan 2021 22:46:46 -0600 Subject: [PATCH 124/132] emails: remove "Valid?" search option. No longer necessary after running previous commit because all invalid email addresses have been purged. --- app/logical/email_validator.rb | 1 - app/models/email_address.rb | 11 ----------- app/views/emails/index.html.erb | 6 ------ 3 files changed, 18 deletions(-) diff --git a/app/logical/email_validator.rb b/app/logical/email_validator.rb index be9951810..6e4b60194 100644 --- a/app/logical/email_validator.rb +++ b/app/logical/email_validator.rb @@ -5,7 +5,6 @@ module EmailValidator # https://www.regular-expressions.info/email.html EMAIL_REGEX = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/ - POSTGRES_EMAIL_REGEX = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" IGNORE_DOTS = %w[gmail.com] IGNORE_PLUS_ADDRESSING = %w[gmail.com hotmail.com outlook.com live.com] diff --git a/app/models/email_address.rb b/app/models/email_address.rb index b43f0a6b4..f0420a734 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -45,21 +45,10 @@ class EmailAddress < ApplicationRecord end end - def self.valid(valid = true) - if valid.to_s.truthy? - where_regex(:address, EmailValidator::POSTGRES_EMAIL_REGEX.to_s) - elsif valid.to_s.falsy? - where_not_regex(:address, EmailValidator::POSTGRES_EMAIL_REGEX.to_s) - else - all - end - end - def self.search(params) q = search_attributes(params, :id, :created_at, :updated_at, :user, :address, :normalized_address, :is_verified, :is_deliverable) q = q.restricted(params[:is_restricted]) - q = q.valid(params[:is_valid]) q = q.apply_default_order(params) q diff --git a/app/views/emails/index.html.erb b/app/views/emails/index.html.erb index b03490a0a..8fc580d14 100644 --- a/app/views/emails/index.html.erb +++ b/app/views/emails/index.html.erb @@ -7,7 +7,6 @@ <%= f.input :address_ilike, label: "Address", input_html: { value: params[:search][:address_ilike] }, hint: "Use * for wildcard" %> <%= f.input :normalized_address_ilike, label: "Normalized Address", input_html: { value: params[:search][:normalized_address_ilike] }, hint: "Use * for wildcard" %> - <%= f.input :is_valid, label: "Valid?", as: :select, include_blank: true, selected: params[:search][:is_valid] %> <%= f.input :is_verified, label: "Verified?", as: :select, include_blank: true, selected: params[:search][:is_verified] %> <%= f.input :is_restricted, label: "Restricted?", as: :select, include_blank: true, selected: params[:search][:is_restricted] %> <%= f.submit "Search" %> @@ -25,11 +24,6 @@ <%= link_to email.normalized_address, emails_path(search: { normalized_address_ilike: email.normalized_address }) %> <% end %> <% end %> - <% t.column "Valid?" do |email| %> - <% if !email.is_valid? %> - <%= link_to "No", emails_path(search: { is_valid: false }) %> - <% end %> - <% end %> <% t.column "Restricted?" do |email| %> <% if email.is_restricted? %> <%= link_to "Yes", emails_path(search: { is_restricted: true }) %> From 11a8c2877b81a91ed0b97664d59bea47272a1923 Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 2 Jan 2021 12:57:18 -0600 Subject: [PATCH 125/132] favorites: refactor favlist order on post page. On the posts show page, in the favorites list, show favorites according to the order they were added to the favorites table, rather than the order they were added to the posts's fav_string. On most posts these should be the same, but on old posts they may be slightly different. The IDs of the first few hundred thousand favorites don't appear to be in chronological order. Probably the original favorite IDs were lost and recreated by a database move at some point in Danbooru's history. The fav_string is also inconsistent with the favorites table in some places (one contains favorites that aren't contained by the other), which also throws off the order. Partially addresses #4562 by eliminating one place where we depended on the fav_string. --- app/helpers/posts_helper.rb | 4 ---- app/models/post.rb | 12 +++++------- app/views/favorites/_update.js.erb | 2 +- .../posts/partials/show/_favorite_list.html.erb | 2 ++ app/views/posts/partials/show/_information.html.erb | 4 +++- 5 files changed, 11 insertions(+), 13 deletions(-) create mode 100644 app/views/posts/partials/show/_favorite_list.html.erb diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 378ad5a71..a521b20e8 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -48,10 +48,6 @@ module PostsHelper end end - def post_favlist(post) - post.favorited_users.reverse_each.map {|user| link_to_user(user)}.join(", ").html_safe - end - def is_pool_selected?(pool) return false if params.key?(:q) return false if params.key?(:favgroup_id) diff --git a/app/models/post.rb b/app/models/post.rb index e89d79e34..4e3300515 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -56,6 +56,7 @@ class Post < ApplicationRecord has_many :approvals, :class_name => "PostApproval", :dependent => :destroy has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy has_many :favorites + has_many :favorited_users, through: :favorites, source: :user has_many :replacements, class_name: "PostReplacement", :dependent => :destroy attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning, :view_count @@ -802,14 +803,11 @@ class Post < ApplicationRecord false end - # users who favorited this post, ordered by users who favorited it first - def favorited_users - favorited_user_ids = fav_string.scan(/\d+/).map(&:to_i) - visible_users = User.find(favorited_user_ids).select do |user| - Pundit.policy!([CurrentUser.user, nil], user).can_see_favorites? + # Users who publicly favorited this post, ordered by time of favorite. + def visible_favorited_users(viewer) + favorited_users.order("favorites.id DESC").select do |fav_user| + Pundit.policy!([viewer, nil], fav_user).can_see_favorites? end - ordered_users = visible_users.index_by(&:id).slice(*favorited_user_ids).values - ordered_users end def favorite_groups diff --git a/app/views/favorites/_update.js.erb b/app/views/favorites/_update.js.erb index 73b71ec61..0c2ece4aa 100644 --- a/app/views/favorites/_update.js.erb +++ b/app/views/favorites/_update.js.erb @@ -14,7 +14,7 @@ $(".fav-buttons").toggleClass("fav-buttons-false").toggleClass("fav-buttons-true <% if policy(@post).can_view_favlist? %> var fav_count = <%= @post.fav_count %>; - $("#favlist").html("<%= j post_favlist(@post) %>"); + $("#favlist").html("<%= j render "posts/partials/show/favorite_list", post: @post %>"); if (fav_count === 0) { $("#show-favlist-link, #hide-favlist-link, #favlist").hide(); diff --git a/app/views/posts/partials/show/_favorite_list.html.erb b/app/views/posts/partials/show/_favorite_list.html.erb new file mode 100644 index 000000000..863a24a7a --- /dev/null +++ b/app/views/posts/partials/show/_favorite_list.html.erb @@ -0,0 +1,2 @@ +<%# post %> +<%= safe_join(post.visible_favorited_users(CurrentUser.user).map { |user| link_to_user(user) }, ", ") %> diff --git a/app/views/posts/partials/show/_information.html.erb b/app/views/posts/partials/show/_information.html.erb index dcfc2b11c..51ce633b2 100644 --- a/app/views/posts/partials/show/_information.html.erb +++ b/app/views/posts/partials/show/_information.html.erb @@ -37,7 +37,9 @@ <% if policy(post).can_view_favlist? %> <%= link_to "Show »", "#", id: "show-favlist-link", style: ("display: none;" if post.fav_count == 0) %> <%= link_to "« Hide", "#", id: "hide-favlist-link", style: "display: none;" %> - + <% end %>
  • Status: From 98ee6c31c1011aa57e22af6383c441b8c4a1a4e7 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 3 Jan 2021 19:01:44 -0600 Subject: [PATCH 126/132] favorites: refactor fav:/ordfav: searches to not use fav_string. Refactor fav: and ordfav: searches to use the favorites table instead of the posts.fav_string. This may be slower for fav: searches. The fav_string effectively treats favorites like secret tags on the post, so fav: searches were effectively the same as tag searches. Now they do a subquery on the favorites table, which may not perform as well for things like multiple fav: metatags or negated fav: metatags. For ordfav: searches, this may be faster. ordfav: searches had a tag match clause (`tag_index @@ 'fav:123'`) in addition to a join on the favs table. This was redundant, and in some cases it inhibited the query planner from choosing a more optimal plan. Partially addresses #4652 by eliminating another place where we depended on the fav_string. --- app/logical/post_query_builder.rb | 7 ++++--- test/unit/post_query_builder_test.rb | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/logical/post_query_builder.rb b/app/logical/post_query_builder.rb index 5a5b11fed..8e6fc980f 100644 --- a/app/logical/post_query_builder.rb +++ b/app/logical/post_query_builder.rb @@ -393,7 +393,8 @@ class PostQueryBuilder favuser = User.find_by_name(username) if favuser.present? && Pundit.policy!([current_user, nil], favuser).can_see_favorites? - tags_include("fav:#{favuser.id}") + favorites = Favorite.from("favorites_#{favuser.id % 100} AS favorites").where(user: favuser) + Post.where(id: favorites.select(:post_id)) else Post.none end @@ -402,8 +403,8 @@ class PostQueryBuilder def ordfav_matches(username) user = User.find_by_name(username) - if user.present? - favorites_include(username).joins(:favorites).merge(Favorite.for_user(user.id)).order("favorites.id DESC") + if user.present? && Pundit.policy!([current_user, nil], user).can_see_favorites? + Post.joins(:favorites).merge(Favorite.for_user(user.id)).order("favorites.id DESC") else Post.none end diff --git a/test/unit/post_query_builder_test.rb b/test/unit/post_query_builder_test.rb index f4882c287..b38ce79e8 100644 --- a/test/unit/post_query_builder_test.rb +++ b/test/unit/post_query_builder_test.rb @@ -191,9 +191,15 @@ class PostQueryBuilderTest < ActiveSupport::TestCase assert_tag_match([post1], "fav:#{user1.name}") assert_tag_match([post2], "fav:#{user2.name}") assert_tag_match([], "fav:#{user3.name}") + + assert_tag_match([], "fav:#{user1.name} fav:#{user2.name}") + assert_tag_match([post1], "fav:#{user1.name} -fav:#{user2.name}") + assert_tag_match([post3], "-fav:#{user1.name} -fav:#{user2.name}") + assert_tag_match([], "fav:dne") assert_tag_match([post3, post2], "-fav:#{user1.name}") + assert_tag_match([post3], "-fav:#{user1.name} -fav:#{user2.name}") assert_tag_match([post3, post2, post1], "-fav:dne") as(user3) do From de16d311351dee580fa11f282d5fb7eefe5d663b Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 3 Jan 2021 19:53:11 -0600 Subject: [PATCH 127/132] favorites: remove is_favorited attribute from post API. * Remove the data-is-favorited attribute from post thumbnails. * Remove the is_favorited attribute from the /posts.json API. * Remove the fav_string attribute from the /posts.json API (only visible to moderators). * Change `Post#favorited_by?` to not use the fav_string. Further addresses #4652 by eliminating the last places where fav_string was used. --- app/models/post.rb | 7 +++---- app/policies/post_policy.rb | 4 ++-- app/presenters/post_presenter.rb | 1 - app/views/posts/partials/index/_preview.html.erb | 6 +----- app/views/posts/partials/show/_options.html.erb | 4 ++-- app/views/posts/show.html.erb | 2 +- test/functional/posts_controller_test.rb | 1 - 7 files changed, 9 insertions(+), 16 deletions(-) diff --git a/app/models/post.rb b/app/models/post.rb index 4e3300515..a4bfc70e8 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -762,12 +762,11 @@ class Post < ApplicationRecord update_column(:fav_count, fav_count) end - def favorited_by?(user_id = CurrentUser.id) - fav_string.match?(/(?:\A| )fav:#{user_id}(?:\Z| )/) + def favorited_by?(user) + return false if user.is_anonymous? + Favorite.exists?(post: self, user: user) end - alias is_favorited? favorited_by? - def append_user_to_fav_string(user_id) update_column(:fav_string, (fav_string + " fav:#{user_id}").strip) clean_fav_string! if clean_fav_string? diff --git a/app/policies/post_policy.rb b/app/policies/post_policy.rb index 316e7034b..3bfa5d05c 100644 --- a/app/policies/post_policy.rb +++ b/app/policies/post_policy.rb @@ -88,11 +88,11 @@ class PostPolicy < ApplicationPolicy def api_attributes attributes = super - attributes += [:has_large, :has_visible_children, :is_favorited?] + attributes += [:has_large, :has_visible_children] attributes += TagCategory.categories.map {|x| "tag_string_#{x}".to_sym} attributes += [:file_url, :large_file_url, :preview_file_url] if visible? attributes -= [:id, :md5, :file_ext] if !visible? - attributes -= [:fav_string] if !user.is_moderator? + attributes -= [:fav_string] attributes end diff --git a/app/presenters/post_presenter.rb b/app/presenters/post_presenter.rb index 60d6ba4b1..6135e1020 100644 --- a/app/presenters/post_presenter.rb +++ b/app/presenters/post_presenter.rb @@ -131,7 +131,6 @@ class PostPresenter "data-source" => post.source, "data-uploader-id" => post.uploader_id, "data-normalized-source" => post.normalized_source, - "data-is-favorited" => post.favorited_by?(CurrentUser.user.id) } if post.visible? diff --git a/app/views/posts/partials/index/_preview.html.erb b/app/views/posts/partials/index/_preview.html.erb index a9f00ebb1..0e03f2613 100644 --- a/app/views/posts/partials/index/_preview.html.erb +++ b/app/views/posts/partials/index/_preview.html.erb @@ -38,11 +38,7 @@ <%= link_to recommended_posts_path(search: { post_id: post.id }), class: "more-recommended-posts", "data-post-id": post.id do %> <%= post.fav_count %> - <% if post.favorited_by?(CurrentUser.id) %> - - <% else %> - - <% end %> +
    more » <% end %> diff --git a/app/views/posts/partials/show/_options.html.erb b/app/views/posts/partials/show/_options.html.erb index d9d2949c8..7190bdf4c 100644 --- a/app/views/posts/partials/show/_options.html.erb +++ b/app/views/posts/partials/show/_options.html.erb @@ -23,10 +23,10 @@ <% if policy(Favorite).create? %>
  • - <%= link_to "Favorite", favorites_path(post_id: post.id), remote: true, method: :post, id: "add-to-favorites", "data-shortcut": "f", style: ("display: none;" if @post.is_favorited?) %> + <%= link_to "Favorite", favorites_path(post_id: post.id), remote: true, method: :post, id: "add-to-favorites", "data-shortcut": "f", style: ("display: none;" if @post.favorited_by?(CurrentUser.user)) %>
  • - <%= link_to "Unfavorite", favorite_path(post), remote: true, method: :delete, id: "remove-from-favorites", "data-shortcut": "shift+f", "data-shortcut-when": ":visible", style: ("display: none;" if !@post.is_favorited?) %> + <%= link_to "Unfavorite", favorite_path(post), remote: true, method: :delete, id: "remove-from-favorites", "data-shortcut": "shift+f", "data-shortcut-when": ":visible", style: ("display: none;" if !@post.favorited_by?(CurrentUser.user)) %>
  • <% end %> <% if policy(post).update? %> diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb index b035b389c..a2f202f27 100644 --- a/app/views/posts/show.html.erb +++ b/app/views/posts/show.html.erb @@ -50,7 +50,7 @@ <% end -%> <% if policy(Favorite).create? %> - <%= content_tag(:div, class: "fav-buttons fav-buttons-#{@post.is_favorited?}") do %> + <%= content_tag(:div, class: "fav-buttons fav-buttons-#{@post.favorited_by?(CurrentUser.user)}") do %> <%= form_tag(favorites_path(post_id: @post.id), method: "post", id: "add-fav-button", "data-remote": true) do %> <%= button_tag tag.i(class: "far fa-heart"), class: "ui-button ui-widget ui-corner-all", "data-disable-with": tag.i(class: "fas fa-spinner fa-spin") %> <% end %> diff --git a/test/functional/posts_controller_test.rb b/test/functional/posts_controller_test.rb index 18ad1ba37..af4c0b2e2 100644 --- a/test/functional/posts_controller_test.rb +++ b/test/functional/posts_controller_test.rb @@ -633,7 +633,6 @@ class PostsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_nil(response.parsed_body["md5"]) assert_nil(response.parsed_body["file_url"]) - assert_nil(response.parsed_body["fav_string"]) end end end From dd430b3065303a4befe598ce0ef8469a7c21a24a Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 3 Jan 2021 20:56:44 -0600 Subject: [PATCH 128/132] Update ruby gems and yarn packages. --- Gemfile.lock | 16 ++--- yarn.lock | 163 ++++++++++++++++++++++++++------------------------- 2 files changed, 91 insertions(+), 88 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1fd362297..ded3f03a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -94,7 +94,7 @@ GEM ansi (1.5.0) ast (2.4.1) aws-eventstream (1.1.0) - aws-partitions (1.413.0) + aws-partitions (1.414.0) aws-sdk-core (3.110.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) @@ -160,9 +160,11 @@ GEM erubi (1.10.0) factory_bot (6.1.0) activesupport (>= 5.0.0) - faraday (1.2.0) + faraday (1.3.0) + faraday-net_http (~> 1.0) multipart-post (>= 1.2, < 3) ruby2_keywords + faraday-net_http (1.0.0) ffaker (2.17.0) ffi (1.14.2) ffi-compiler (1.0.1) @@ -181,14 +183,14 @@ GEM http-form_data (2.3.0) http-parser (1.2.2) ffi-compiler - i18n (1.8.5) + i18n (1.8.6) concurrent-ruby (~> 1.0) ipaddress_2 (0.13.0) jmespath (1.4.0) json (2.5.1) jwt (2.2.2) kgio (2.11.3) - listen (3.3.3) + listen (3.4.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) loofah (2.8.0) @@ -253,7 +255,7 @@ GEM pundit (2.1.0) activesupport (>= 3.0.0) rack (2.2.3) - rack-mini-profiler (2.2.1) + rack-mini-profiler (2.3.0) rack (>= 1.2.0) rack-proxy (0.6.5) rack @@ -311,13 +313,13 @@ GEM rubocop-ast (>= 1.2.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (1.3.0) + rubocop-ast (1.4.0) parser (>= 2.7.1.5) rubocop-rails (2.9.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 0.90.0, < 2.0) - ruby-progressbar (1.10.1) + ruby-progressbar (1.11.0) ruby-vips (2.0.17) ffi (~> 1.9) ruby2_keywords (0.0.2) diff --git a/yarn.lock b/yarn.lock index fd85d4cd7..fbc6f5550 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1053,9 +1053,9 @@ integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== "@types/node@*": - version "14.14.16" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.16.tgz#3cc351f8d48101deadfed4c9e4f116048d437b4b" - integrity sha512-naXYePhweTi+BMv11TgioE2/FXU4fSl29HAH1ffxVciNsH3rYXjNP2yM8wqmSm7jS20gM8TIklKiTen+1iVncw== + version "14.14.19" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.19.tgz#5135176a8330b88ece4e9ab1fdcfc0a545b4bab4" + integrity sha512-4nhBPStMK04rruRVtVc6cDqhu7S9GZai0fpXgPXrFpcPX6Xul8xnrjSdGB4KPBVYG/R5+fXWdCM8qBoiULWGPQ== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -1295,6 +1295,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -1812,7 +1822,7 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.15.0, browserslist@^4.6.4: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0, browserslist@^4.6.4: version "4.16.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== @@ -2040,9 +2050,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: - version "1.0.30001170" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001170.tgz#0088bfecc6a14694969e391cc29d7eb6362ca6a7" - integrity sha512-Dd4d/+0tsK0UNLrZs3CvNukqalnVTRrxb5mcQm8rHL49t7V5ZaTygwXkrq+FB+dVDf++4ri8eJnFEJAB8332PA== + version "1.0.30001171" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001171.tgz#3291e11e02699ad0a29e69b8d407666fc843eba7" + integrity sha512-5Alrh8TTYPG9IH4UkRqEBZoEToWRLvPbSQokvzSz0lii8/FOWKG4keO1HoYfPWs8IF/NH/dyNPg1cmJGvV3Zlg== case-sensitive-paths-webpack-plugin@^2.3.0: version "2.3.0" @@ -2422,22 +2432,22 @@ copy-descriptor@^0.1.0: integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= core-js-compat@^3.8.0: - version "3.8.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.1.tgz#8d1ddd341d660ba6194cbe0ce60f4c794c87a36e" - integrity sha512-a16TLmy9NVD1rkjUGbwuyWkiDoN0FDpAwrfLONvHFQx0D9k7J9y0srwMT8QP/Z6HE3MIFaVynEeYwZwPX1o5RQ== + version "3.8.2" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.2.tgz#3717f51f6c3d2ebba8cbf27619b57160029d1d4c" + integrity sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ== dependencies: - browserslist "^4.15.0" + browserslist "^4.16.0" semver "7.0.0" core-js-pure@^3.0.0: - version "3.8.1" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.8.1.tgz#23f84048f366fdfcf52d3fd1c68fec349177d119" - integrity sha512-Se+LaxqXlVXGvmexKGPvnUIYC1jwXu1H6Pkyb3uBM5d8/NELMYCHs/4/roD7721NxrTLyv7e5nXd5/QLBO+10g== + version "3.8.2" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.8.2.tgz#286f885c0dac1cdcd6d78397392abc25ddeca225" + integrity sha512-v6zfIQqL/pzTVAbZvYUozsxNfxcFb6Ks3ZfEbuneJl3FW9Jb8F6vLWB6f+qTmAu72msUdyb84V8d/yBFf7FNnw== core-js@^3.6.5: - version "3.8.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.1.tgz#f51523668ac8a294d1285c3b9db44025fda66d47" - integrity sha512-9Id2xHY1W7m8hCl8NkhQn5CufmF/WuR30BTRewvCXc1aZd3kMECwNZ69ndLbekKfakw9Rf2Xyc+QR6E7Gg+obg== + version "3.8.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.2.tgz#0a1fd6709246da9ca8eff5bb0cbd15fba9ac7044" + integrity sha512-FfApuSRgrR6G5s58casCBd9M2k+4ikuu4wbW6pJyYU7bd9zvFc9qf7vr5xmrZOhT9nn+8uwlH1oRR9jTnFoA3A== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -2758,7 +2768,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3: dependencies: ms "2.0.0" -debug@^3.1.1, debug@^3.2.5: +debug@^3.1.1, debug@^3.2.6: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== @@ -3301,9 +3311,9 @@ eslint-visitor-keys@^2.0.0: integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@^7.0.0: - version "7.16.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.16.0.tgz#a761605bf9a7b32d24bb7cde59aeb0fd76f06092" - integrity sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw== + version "7.17.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.17.0.tgz#4ccda5bf12572ad3bf760e6f195886f50569adb0" + integrity sha512-zJk08MiBgwuGoxes5sSQhOtibZ75pz0J35XTRlZOk9xMffhpA9BTbQZxoXZzOl5zMbleShbGwtw+1kGferfFwQ== dependencies: "@babel/code-frame" "^7.0.0" "@eslint/eslintrc" "^0.2.2" @@ -3590,14 +3600,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -faye-websocket@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" - integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= - dependencies: - websocket-driver ">=0.5.1" - -faye-websocket@~0.11.1: +faye-websocket@^0.11.3: version "0.11.3" resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e" integrity sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA== @@ -4349,9 +4352,9 @@ http-errors@~1.7.2: toidentifier "1.0.0" http-parser-js@>=0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" - integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + version "0.5.3" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" + integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== http-proxy-middleware@0.19.1: version "0.19.1" @@ -5007,6 +5010,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -5022,7 +5030,7 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= -json3@^3.3.2: +json3@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== @@ -5484,22 +5492,17 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.44.0: - version "1.44.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== - -"mime-db@>= 1.43.0 < 2": +mime-db@1.45.0, "mime-db@>= 1.43.0 < 2": version "1.45.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.27" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + version "2.1.28" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== dependencies: - mime-db "1.44.0" + mime-db "1.45.0" mime@1.6.0: version "1.6.0" @@ -7158,9 +7161,9 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2 supports-color "^6.1.0" preact@^10.4.6: - version "10.5.7" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.7.tgz#f1d84725539e18f7ccbea937cf3db5895661dbd3" - integrity sha512-4oEpz75t/0UNcwmcsjk+BIcDdk68oao+7kxcpc1hQPNs2Oo3ZL9xFz8UBf350mxk/VEdD41L5b4l2dE3Ug3RYg== + version "10.5.9" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.9.tgz#8caba9288b4db1d593be2317467f8735e43cda0b" + integrity sha512-X4m+4VMVINl/JFQKALOCwa3p8vhMAhBvle0hJ/W44w/WWfNb2TA7RNicDV3K2dNVs57f61GviEnVLiwN+fxiIg== prelude-ls@^1.2.1: version "1.2.1" @@ -7607,6 +7610,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -7831,7 +7839,7 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selfsigned@^1.10.7: +selfsigned@^1.10.8: version "1.10.8" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" integrity sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w== @@ -8064,26 +8072,26 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -sockjs-client@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" - integrity sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g== +sockjs-client@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.5.0.tgz#2f8ff5d4b659e0d092f7aba0b7c386bd2aa20add" + integrity sha512-8Dt3BDi4FYNrCFGTL/HtwVzkARrENdwOUf1ZoW/9p3M8lZdFT35jVdrHza+qgxuG9H3/shR4cuX/X9umUrjP8Q== dependencies: - debug "^3.2.5" + debug "^3.2.6" eventsource "^1.0.7" - faye-websocket "~0.11.1" - inherits "^2.0.3" - json3 "^3.3.2" - url-parse "^1.4.3" + faye-websocket "^0.11.3" + inherits "^2.0.4" + json3 "^3.3.3" + url-parse "^1.4.7" -sockjs@0.3.20: - version "0.3.20" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.20.tgz#b26a283ec562ef8b2687b44033a4eeceac75d855" - integrity sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA== +sockjs@^0.3.21: + version "0.3.21" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.21.tgz#b34ffb98e796930b60a0cfa11904d6a339a7d417" + integrity sha512-DhbPFGpxjc6Z3I+uX07Id5ZO2XwYsWOrYjaSeieES78cq+JaJvVe5q/m1uvjIQhXinhIeCFRH6JgXe+mvVMyXw== dependencies: - faye-websocket "^0.10.0" + faye-websocket "^0.11.3" uuid "^3.4.0" - websocket-driver "0.6.5" + websocket-driver "^0.7.4" sort-keys@^1.0.0: version "1.1.2" @@ -8592,11 +8600,11 @@ svgo@^1.0.0: util.promisify "~1.0.0" table@^6.0.3, table@^6.0.4: - version "6.0.4" - resolved "https://registry.yarnpkg.com/table/-/table-6.0.4.tgz#c523dd182177e926c723eb20e1b341238188aa0d" - integrity sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw== + version "6.0.6" + resolved "https://registry.yarnpkg.com/table/-/table-6.0.6.tgz#e9223f1e851213e2e43ab584b0fec33fb09a8e7a" + integrity sha512-OInCtPmDNieVBkVFi6C8RwU2S2H0h8mF3e3TQK4nreaUNCpooQUkI+A/KuEkm5FawfhWIfNqG+qfelVVR+V00g== dependencies: - ajv "^6.12.4" + ajv "^7.0.2" lodash "^4.17.20" slice-ansi "^4.0.0" string-width "^4.2.0" @@ -9084,7 +9092,7 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse@^1.4.3: +url-parse@^1.4.3, url-parse@^1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== @@ -9266,9 +9274,9 @@ webpack-dev-middleware@^3.7.2: webpack-log "^2.0.0" webpack-dev-server@^3.8.0: - version "3.11.0" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c" - integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== + version "3.11.1" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.1.tgz#c74028bf5ba8885aaf230e48a20e8936ab8511f0" + integrity sha512-u4R3mRzZkbxQVa+MBWi2uVpB5W59H3ekZAJsQlKUTdl7Elcah2EhygTPLmeFXybQkf9i2+L0kn7ik9SnXa6ihQ== dependencies: ansi-html "0.0.7" bonjour "^3.5.0" @@ -9290,11 +9298,11 @@ webpack-dev-server@^3.8.0: p-retry "^3.0.1" portfinder "^1.0.26" schema-utils "^1.0.0" - selfsigned "^1.10.7" + selfsigned "^1.10.8" semver "^6.3.0" serve-index "^1.9.1" - sockjs "0.3.20" - sockjs-client "1.4.0" + sockjs "^0.3.21" + sockjs-client "^1.5.0" spdy "^4.0.2" strip-ansi "^3.0.1" supports-color "^6.1.0" @@ -9349,14 +9357,7 @@ webpack@^4.44.1: watchpack "^1.7.4" webpack-sources "^1.4.1" -websocket-driver@0.6.5: - version "0.6.5" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" - integrity sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY= - dependencies: - websocket-extensions ">=0.1.1" - -websocket-driver@>=0.5.1: +websocket-driver@>=0.5.1, websocket-driver@^0.7.4: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== From 6793aedf8186e1dcbd11b7be9f519cb827e76275 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 3 Jan 2021 23:56:01 -0600 Subject: [PATCH 129/132] Fix #4650: Differentiate between aliases and corrections in autocomplete. Display a red wavy underline beneath misspelled tags in autocomplete. We use an inline image for the underline instead of the native `text-decoration: red wavy underline` property because the native underline is too big and ugly, and we have no way to adjust it. Making a nice-looking wavy underline in CSS is surprisingly difficult. This turned out to be the cleanest way. --- app/javascript/src/styles/base/040_colors.css | 1 + app/javascript/src/styles/common/autocomplete.scss | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/javascript/src/styles/base/040_colors.css b/app/javascript/src/styles/base/040_colors.css index 4cf8a227e..1e98f1a6f 100644 --- a/app/javascript/src/styles/base/040_colors.css +++ b/app/javascript/src/styles/base/040_colors.css @@ -96,6 +96,7 @@ --autocomplete-selected-background-color: var(--subnav-menu-background-color); --autocomplete-border: 1px solid #CCC; --autocomplete-arrow-color: var(--text-color); + --autocomplete-tag-autocorrect-underline: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAHElEQVQYV2NkQAL/GRj+M4IJBgY4zQhSABMEsQHMOAgCT5YN9gAAAABJRU5ErkJggg==); --diff-list-added-color: green; --diff-list-removed-color: red; diff --git a/app/javascript/src/styles/common/autocomplete.scss b/app/javascript/src/styles/common/autocomplete.scss index e8a30e453..b90dbd9e2 100644 --- a/app/javascript/src/styles/common/autocomplete.scss +++ b/app/javascript/src/styles/common/autocomplete.scss @@ -22,7 +22,14 @@ color: var(--autocomplete-arrow-color); } + /* Display a red wavy underline beneath misspelled tags. */ + /* https://stackoverflow.com/a/28152272 */ li[data-autocomplete-type="tag-autocorrect"] .autocomplete-antecedent { - text-decoration: dotted underline; + position: relative; + display: inline-block; + background: var(--autocomplete-tag-autocorrect-underline); + background-repeat: repeat-x; + background-position-y: 1.2em; + line-height: 1.5em; } } From 69cfa1696a7fbda73b964490988ff1b03f5b46c2 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 4 Jan 2021 00:02:16 -0600 Subject: [PATCH 130/132] html: disable browser spellcheck on all non-DText inputs. Disable the browser's native spellchecking ability on all form inputs, except for DText inputs. We do this by setting `spellcheck="false"` on the tag, and `spellcheck="true"` on DText tags. This fixes browsers displaying a red wavy underline beneath tags in the tag search box, among other places. We disable spellchecking globally because most form inputs, except for DText inputs, aren't meant for natural English language. --- app/helpers/application_helper.rb | 1 + app/logical/dtext_input.rb | 2 +- app/views/posts/partials/show/_edit.html.erb | 2 +- app/views/uploads/new.html.erb | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 57f4819c0..f0c161641 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -273,6 +273,7 @@ module ApplicationHelper { lang: "en", class: "c-#{controller_param} a-#{action_param}", + spellcheck: "false", data: { controller: controller_param, action: action_param, diff --git a/app/logical/dtext_input.rb b/app/logical/dtext_input.rb index a1910e933..77e55621a 100644 --- a/app/logical/dtext_input.rb +++ b/app/logical/dtext_input.rb @@ -16,7 +16,7 @@ class DtextInput < SimpleForm::Inputs::Base t = template merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) - t.tag.div(class: "dtext-previewable") do + t.tag.div(class: "dtext-previewable", spellcheck: true) do if options[:inline] t.concat @builder.text_field(attribute_name, merged_input_options) else diff --git a/app/views/posts/partials/show/_edit.html.erb b/app/views/posts/partials/show/_edit.html.erb index 1c47dbc96..f7ee181b2 100644 --- a/app/views/posts/partials/show/_edit.html.erb +++ b/app/views/posts/partials/show/_edit.html.erb @@ -54,7 +54,7 @@
    - <%= f.input :tag_string, label: false, input_html: { size: "60x5", spellcheck: false, "data-autocomplete": "tag-edit", "data-shortcut": "e", value: post.presenter.split_tag_list_text + " " } %> + <%= f.input :tag_string, label: false, input_html: { size: "60x5", "data-autocomplete": "tag-edit", "data-shortcut": "e", value: post.presenter.split_tag_list_text + " " } %>
    <%= render "related_tags/buttons" %> diff --git a/app/views/uploads/new.html.erb b/app/views/uploads/new.html.erb index 51552cedb..a41132c1e 100644 --- a/app/views/uploads/new.html.erb +++ b/app/views/uploads/new.html.erb @@ -69,7 +69,7 @@
    - <%= f.input :tag_string, label: false, input_html: { size: "60x5", "data-autocomplete": "tag-edit", "data-shortcut": "e", spellcheck: false, value: params[:tag_string] } %> + <%= f.input :tag_string, label: false, input_html: { size: "60x5", "data-autocomplete": "tag-edit", "data-shortcut": "e", value: params[:tag_string] } %> <%= render "related_tags/buttons" %> From 257fa3d9c108def5ee31f95b46b43cecde9a8184 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 4 Jan 2021 01:02:39 -0600 Subject: [PATCH 131/132] Fix #4645: Builders can alias empty non-artist tags --- app/logical/bulk_update_request_processor.rb | 11 +++++++++-- .../bulk_update_requests_controller_test.rb | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/logical/bulk_update_request_processor.rb b/app/logical/bulk_update_request_processor.rb index bb4ef8271..a6e257a66 100644 --- a/app/logical/bulk_update_request_processor.rb +++ b/app/logical/bulk_update_request_processor.rb @@ -212,11 +212,18 @@ class BulkUpdateRequestProcessor end.join("\n") end + # Tag move is allowed if: + # + # * The antecedent tag is an artist tag. + # * The consequent_tag is a nonexistent tag, an empty tag (of any type), or an artist tag. + # * Both tags have less than 100 posts. def self.is_tag_move_allowed?(antecedent_name, consequent_name) antecedent_tag = Tag.find_by_name(Tag.normalize_name(antecedent_name)) consequent_tag = Tag.find_by_name(Tag.normalize_name(consequent_name)) - (antecedent_tag.blank? || antecedent_tag.empty? || (antecedent_tag.artist? && antecedent_tag.post_count <= 100)) && - (consequent_tag.blank? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count <= 100)) + antecedent_allowed = antecedent_tag.present? && antecedent_tag.artist? && antecedent_tag.post_count < 100 + consequent_allowed = consequent_tag.nil? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count < 100) + + antecedent_allowed && consequent_allowed end end diff --git a/test/functional/bulk_update_requests_controller_test.rb b/test/functional/bulk_update_requests_controller_test.rb index b72db21bc..0864f8a54 100644 --- a/test/functional/bulk_update_requests_controller_test.rb +++ b/test/functional/bulk_update_requests_controller_test.rb @@ -139,6 +139,17 @@ class BulkUpdateRequestsControllerTest < ActionDispatch::IntegrationTest end context "for a builder" do + should "fail when moving a non-artist tag" do + create(:tag, name: "sfw", post_count: 0) + @bulk_update_request = create(:bulk_update_request, script: "alias sfw -> rating:s") + + post_auth approve_bulk_update_request_path(@bulk_update_request), @builder + + assert_response 403 + assert_equal("pending", @bulk_update_request.reload.status) + assert_equal(false, TagAlias.exists?(antecedent_name: "sfw", consequent_name: "rating:s")) + end + should "fail for a large artist move" do create(:tag, name: "artist1", category: Tag.categories.artist, post_count: 1000) @bulk_update_request = create(:bulk_update_request, script: "create alias artist1 -> artist2") From 9008e836e45723b5a661e3e99908ae91d9e63c69 Mon Sep 17 00:00:00 2001 From: evazion Date: Mon, 4 Jan 2021 01:09:06 -0600 Subject: [PATCH 132/132] BURs: raise limit on Builder artist tag moves from 100 to 200 posts. --- app/logical/bulk_update_request_processor.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/logical/bulk_update_request_processor.rb b/app/logical/bulk_update_request_processor.rb index a6e257a66..ea77175fb 100644 --- a/app/logical/bulk_update_request_processor.rb +++ b/app/logical/bulk_update_request_processor.rb @@ -1,5 +1,11 @@ class BulkUpdateRequestProcessor + # Maximum tag size allowed by the rename command before an alias must be used. MAXIMUM_RENAME_COUNT = 200 + + # Maximum size of artist tags movable by builders. + MAXIMUM_BUILDER_MOVE_COUNT = 200 + + # Maximum number of lines a BUR may have. MAXIMUM_SCRIPT_LENGTH = 100 include ActiveModel::Validations @@ -216,13 +222,13 @@ class BulkUpdateRequestProcessor # # * The antecedent tag is an artist tag. # * The consequent_tag is a nonexistent tag, an empty tag (of any type), or an artist tag. - # * Both tags have less than 100 posts. + # * Both tags have less than 200 posts. def self.is_tag_move_allowed?(antecedent_name, consequent_name) antecedent_tag = Tag.find_by_name(Tag.normalize_name(antecedent_name)) consequent_tag = Tag.find_by_name(Tag.normalize_name(consequent_name)) - antecedent_allowed = antecedent_tag.present? && antecedent_tag.artist? && antecedent_tag.post_count < 100 - consequent_allowed = consequent_tag.nil? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count < 100) + antecedent_allowed = antecedent_tag.present? && antecedent_tag.artist? && antecedent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT + consequent_allowed = consequent_tag.nil? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT) antecedent_allowed && consequent_allowed end