diff --git a/.rubocop.yml b/.rubocop.yml index 6a4974108..b78883113 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -155,4 +155,4 @@ Style/TrailingCommaInArrayLiteral: EnforcedStyleForMultiline: consistent_comma Style/TrailingCommaInHashLiteral: - EnforcedStyleForMultiline: consistent_comma + EnforcedStyleForMultiline: consistent_comma \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f26e4fa29..4ebf69619 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -498,7 +498,7 @@ Layout/SpaceInsideBlockBraces: # SupportedStylesForEmptyBraces: space, no_space Layout/SpaceInsideHashLiteralBraces: Exclude: - - 'app/controllers/admin/staff_notes_controller.rb' + - 'app/controllers/staff_notes_controller.rb' - 'app/controllers/admin/users_controller.rb' - 'app/controllers/application_controller.rb' - 'app/controllers/comment_votes_controller.rb' @@ -2416,7 +2416,7 @@ Style/StringLiterals: Exclude: - 'Gemfile' - 'Rakefile' - - 'app/controllers/admin/staff_notes_controller.rb' + - 'app/controllers/staff_notes_controller.rb' - 'app/controllers/blips_controller.rb' - 'app/controllers/comments_controller.rb' - 'app/controllers/edit_histories_controller.rb' diff --git a/Gemfile b/Gemfile index 09b10c97f..b681721c0 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ gem 'marcel' gem 'sidekiq-unique-jobs' gem 'redis' gem 'request_store' +gem "zxcvbn-ruby", require: "zxcvbn" gem "diffy" gem "rugged" diff --git a/Gemfile.lock b/Gemfile.lock index d924493e8..17e4506a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -376,6 +376,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) zeitwerk (2.6.13) + zxcvbn-ruby (1.2.0) PLATFORMS ruby @@ -426,6 +427,7 @@ DEPENDENCIES streamio-ffmpeg webmock webpacker (>= 4.0.x) + zxcvbn-ruby BUNDLED WITH 2.4.10 diff --git a/app/controllers/admin/staff_notes_controller.rb b/app/controllers/admin/staff_notes_controller.rb deleted file mode 100644 index 2b5fad79f..000000000 --- a/app/controllers/admin/staff_notes_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Admin - class StaffNotesController < ApplicationController - before_action :can_view_staff_notes_only - respond_to :html - - def index - @user = User.find_by(id: params[:user_id]) - @notes = StaffNote.search(search_params.merge({ user_id: params[:user_id] })).includes(:user, :creator).paginate(params[:page], limit: params[:limit]) - respond_with(@notes) - end - - def new - @user = User.find(params[:user_id]) - @staff_note = StaffNote.new(note_params) - respond_with(@note) - end - - def create - @user = User.find(params[:user_id]) - @staff_note = StaffNote.create(note_params.merge({creator: CurrentUser.user, user_id: @user.id})) - flash[:notice] = @staff_note.valid? ? "Staff Note added" : @staff_note.errors.full_messages.join("; ") - respond_with(@staff_note) do |format| - format.html do - redirect_back fallback_location: admin_staff_notes_path - end - end - end - - private - - def search_params - permit_search_params(%i[creator_id creator_name user_id user_name resolved body_matches without_system_user]) - end - - def note_params - params.fetch(:staff_note, {}).permit(%i[body]) - end - end -end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 490cafc3d..68d84c5f8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -165,6 +165,7 @@ class ApplicationController < ActionController::Base def set_current_user SessionLoader.new(request).load + session.send(:load!) unless session.send(:loaded?) end def reset_current_user @@ -173,6 +174,14 @@ class ApplicationController < ActionController::Base CurrentUser.safe_mode = Danbooru.config.safe_mode? end + def requires_reauthentication + return redirect_to(new_session_path(url: request.fullpath)) if CurrentUser.user.is_anonymous? + last_authenticated_at = session[:last_authenticated_at] + if last_authenticated_at.blank? || Time.zone.parse(last_authenticated_at) < 1.hour.ago + redirect_to(confirm_password_session_path(url: request.fullpath)) + end + end + def user_access_check(method) if !CurrentUser.user.send(method) || CurrentUser.user.is_banned? || IpBan.is_banned?(CurrentUser.ip_addr) access_denied diff --git a/app/controllers/maintenance/user/api_keys_controller.rb b/app/controllers/maintenance/user/api_keys_controller.rb index f2809e1dc..f6b0e5d02 100644 --- a/app/controllers/maintenance/user/api_keys_controller.rb +++ b/app/controllers/maintenance/user/api_keys_controller.rb @@ -3,17 +3,14 @@ module Maintenance module User class ApiKeysController < ApplicationController + before_action :requires_reauthentication before_action :member_only - before_action :authenticate!, except: [:show] - rescue_from ::SessionLoader::AuthenticationFailure, with: :authentication_failed + before_action :load_apikey respond_to :html def show end - def view - end - def update @api_key.regenerate! redirect_to(user_api_key_path(CurrentUser.user), notice: "API key regenerated") @@ -24,19 +21,10 @@ module Maintenance redirect_to(CurrentUser.user) end - protected + private - def authenticate! - if ::User.authenticate(CurrentUser.user.name, params[:user][:password]) == CurrentUser.user - @api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user) - @password = params[:user][:password] - else - raise ::SessionLoader::AuthenticationFailure - end - end - - def authentication_failed - redirect_to(user_api_key_path(CurrentUser.user), notice: "Password was incorrect.") + def load_apikey + @api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user) end end end diff --git a/app/controllers/mod_actions_controller.rb b/app/controllers/mod_actions_controller.rb index b3fd2076b..c13f61348 100644 --- a/app/controllers/mod_actions_controller.rb +++ b/app/controllers/mod_actions_controller.rb @@ -5,19 +5,20 @@ class ModActionsController < ApplicationController def index @mod_actions = ModActionDecorator.decorate_collection( - ModAction.includes(:creator).search(search_params).paginate(params[:page], limit: params[:limit]), + ModAction.visible(CurrentUser.user).includes(:creator).search(search_params).paginate(params[:page], limit: params[:limit]), ) - respond_with(@mod_actions) do |format| - format.json do - render json: @mod_actions.to_json - end - end + respond_with(@mod_actions) end def show @mod_action = ModAction.find(params[:id]) + check_permission(@mod_action) respond_with(@mod_action) do |fmt| fmt.html { redirect_to mod_actions_path(search: { id: @mod_action.id }) } end end + + def check_permission(mod_action) + raise(User::PrivilegeError) unless mod_action.can_view?(CurrentUser.user) + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a95243a6f..0a62a3f34 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,26 +6,31 @@ class SessionsController < ApplicationController end def create + sparams = params.fetch(:session, {}).slice(:url, :name, :password, :remember) if RateLimiter.check_limit("login:#{request.remote_ip}", 15, 12.hours) DanbooruLogger.add_attributes("user.login" => "rate_limited") - return redirect_to(new_session_path, :notice => "Username/Password was incorrect") + return redirect_to(new_session_path, notice: "Username/Password was incorrect") end - session_creator = SessionCreator.new(session, cookies, params[:name], params[:password], request.remote_ip, params[:remember], request.ssl?) + session_creator = SessionCreator.new(request, session, cookies, sparams[:name], sparams[:password], sparams[:remember].to_s.truthy?) if session_creator.authenticate - url = params[:url] if params[:url] && params[:url].start_with?("/") && !params[:url].start_with?("//") + url = sparams[:url] if sparams[:url] && sparams[:url].start_with?("/") && !sparams[:url].start_with?("//") DanbooruLogger.add_attributes("user.login" => "success") - redirect_to(url || posts_path, :notice => "You are now logged in") + redirect_to(url || posts_path) else RateLimiter.hit("login:#{request.remote_ip}", 6.hours) DanbooruLogger.add_attributes("user.login" => "fail") - redirect_to(new_session_path, :notice => "Username/Password was incorrect") + redirect_back(fallback_location: new_session_path, notice: "Username/Password was incorrect") end end def destroy session.delete(:user_id) - cookies.delete :remember - redirect_to(posts_path, :notice => "You are now logged out") + cookies.delete(:remember) + session.delete(:last_authenticated_at) + redirect_to(posts_path, notice: "You are now logged out") + end + + def confirm_password end end diff --git a/app/controllers/staff_notes_controller.rb b/app/controllers/staff_notes_controller.rb new file mode 100644 index 000000000..cefaac26b --- /dev/null +++ b/app/controllers/staff_notes_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class StaffNotesController < ApplicationController + before_action :can_view_staff_notes_only + before_action :load_staff_note, only: %i[show edit update delete undelete] + before_action :check_edit_privilege, only: %i[update] + before_action :check_delete_privilege, only: %i[delete undelete] + respond_to :html, :json + + def index + @user = User.find_by(id: params[:user_id]) + @notes = StaffNote.search(search_params.merge({ user_id: params[:user_id] })).includes(:user, :creator).paginate(params[:page], limit: params[:limit]) + respond_with(@notes) + end + + def show + respond_with(@staff_note) + end + + def new + @user = User.find(params[:user_id]) + @staff_note = StaffNote.new(staff_note_params) + respond_with(@note) + end + + def edit + respond_with(@staff_note) + end + + def create + @user = User.find(params[:user_id]) + @staff_note = StaffNote.create(staff_note_params.merge({ user_id: @user.id })) + flash[:notice] = @staff_note.valid? ? "Staff Note added" : @staff_note.errors.full_messages.join("; ") + respond_with(@staff_note) do |format| + format.html do + redirect_back fallback_location: staff_notes_path + end + end + end + + def update + @staff_note.update(staff_note_params) + redirect_back(fallback_location: staff_notes_path) + end + + def delete + @staff_note.update(is_deleted: true) + redirect_back(fallback_location: staff_notes_path) + end + + def undelete + @staff_note.update(is_deleted: false) + redirect_back(fallback_location: staff_notes_path) + end + + private + + def search_params + permit_search_params(%i[creator_id creator_name updater_id updater_name user_id user_name body_matches without_system_user include_deleted]) + end + + def staff_note_params + params.fetch(:staff_note, {}).permit(%i[body]) + end + + def load_staff_note + @staff_note = StaffNote.find(params[:id]) + end + + def check_edit_privilege + raise User::PrivilegeError unless @staff_note.can_edit?(CurrentUser.user) + end + + def check_delete_privilege + raise User::PrivilegeError unless @staff_note.can_delete?(CurrentUser.user) + end +end diff --git a/app/decorators/mod_action_decorator.rb b/app/decorators/mod_action_decorator.rb index 45d12b184..a0b7f71c0 100644 --- a/app/decorators/mod_action_decorator.rb +++ b/app/decorators/mod_action_decorator.rb @@ -55,7 +55,7 @@ class ModActionDecorator < ApplicationDecorator when "artist_delete" "Deleted artist ##{vals['artist_id']} (#{vals['artist_name']})" when "artist_page_rename" - "Renamed artist page (\"#{vals['old_name']}\":/artists/show_or_new?name=#{vals['old_name']} -> \"#{vals['new_name']}\":/artists/show_or_new?name=#{vals['new_name']})" + "Renamed artist page (\"#{vals['old_name']}\":/artists/show_or_new?name=#{vals['old_name']} โ†’ \"#{vals['new_name']}\":/artists/show_or_new?name=#{vals['new_name']})" when "artist_page_lock" "Locked artist page artist ##{vals['artist_page']}" when "artist_page_unlock" @@ -77,6 +77,16 @@ class ModActionDecorator < ApplicationDecorator when "avoid_posting_undelete" "Undeleted \"avoid posting ##{vals['id']}\":/avoid_postings/#{vals['id']} for [[#{vals['artist_name']}]]" + ### Staff Note ### + when "staff_note_create" + "Created \"staff note ##{vals['id']}\":/staff_notes/#{vals['id']} for #{user}\n#{vals['body']}" + when "staff_note_update" + "Updated \"staff note ##{vals['id']}\":/staff_notes/#{vals['id']} for #{user}\n#{vals['body']}" + when "staff_note_delete" + "Deleted \"staff note ##{vals['id']}\":/staff_notes/#{vals['id']} for #{user}" + when "staff_note_undelete" + "Undeleted \"staff note ##{vals['id']}\":/staff_notes/#{vals['id']} for #{user}" + ### User ### when "user_delete" @@ -276,7 +286,7 @@ class ModActionDecorator < ApplicationDecorator ### BURs ### when "mass_update" - "Mass updated [[#{vals['antecedent']}]] -> [[#{vals['consequent']}]]" + "Mass updated [[#{vals['antecedent']}]] โ†’ [[#{vals['consequent']}]]" when "nuke_tag" "Nuked tag [[#{vals['tag_name']}]]" @@ -319,7 +329,7 @@ class ModActionDecorator < ApplicationDecorator "Edited whitelist entry" else if vals['old_pattern'] && vals['old_pattern'] != vals['pattern'] && CurrentUser.is_admin? - "Edited whitelist entry '#{vals['old_pattern']}' -> '#{vals['pattern']}'" + "Edited whitelist entry '#{vals['old_pattern']}' โ†’ '#{vals['pattern']}'" else "Edited whitelist entry '#{CurrentUser.is_admin? ? vals['pattern'] : vals['note']}'" end diff --git a/app/indexes/post_index.rb b/app/indexes/post_index.rb index ac8d46d4a..f755adf8f 100644 --- a/app/indexes/post_index.rb +++ b/app/indexes/post_index.rb @@ -28,6 +28,7 @@ module PostIndex tag_count_general: { type: "integer" }, tag_count_artist: { type: "integer" }, + tag_count_contributor: { type: "integer" }, tag_count_character: { type: "integer" }, tag_count_copyright: { type: "integer" }, tag_count_meta: { type: "integer" }, @@ -238,37 +239,38 @@ module PostIndex tag_count: tag_count, change_seq: change_seq, - tag_count_general: tag_count_general, - tag_count_artist: tag_count_artist, - tag_count_character: tag_count_character, - tag_count_copyright: tag_count_copyright, - tag_count_meta: tag_count_meta, - tag_count_species: tag_count_species, - tag_count_invalid: tag_count_invalid, - tag_count_lore: tag_count_lore, - tag_count_people: tag_count_people, - comment_count: options[:comment_count] || comment_count, + tag_count_general: tag_count_general, + tag_count_artist: tag_count_artist, + tag_count_contributor: tag_count_contributor, + tag_count_character: tag_count_character, + tag_count_copyright: tag_count_copyright, + tag_count_meta: tag_count_meta, + tag_count_species: tag_count_species, + tag_count_invalid: tag_count_invalid, + tag_count_lore: tag_count_lore, + tag_count_people: tag_count_people, - file_size: file_size, - parent: parent_id, - pools: options[:pools] || ::Pool.where("? = ANY(post_ids)", id).pluck(:id), - sets: options[:sets] || ::PostSet.where("? = ANY(post_ids)", id).pluck(:id), - commenters: options[:commenters] || ::Comment.undeleted.where(post_id: id).pluck(:creator_id), - noters: options[:noters] || ::Note.active.where(post_id: id).pluck(:creator_id), - faves: options[:faves] || ::Favorite.where(post_id: id).pluck(:user_id), - upvotes: options[:upvotes] || ::PostVote.where(post_id: id).where("score > 0").pluck(:user_id), - downvotes: options[:downvotes] || ::PostVote.where(post_id: id).where("score < 0").pluck(:user_id), - children: options[:children] || ::Post.where(parent_id: id).pluck(:id), - notes: options[:notes] || ::Note.active.where(post_id: id).pluck(:body), - uploader: uploader_id, - approver: approver_id, - deleter: options[:deleter] || ::PostFlag.where(post_id: id, is_resolved: false, is_deletion: true).order(id: :desc).first&.creator_id, - del_reason: options[:del_reason] || ::PostFlag.where(post_id: id, is_resolved: false, is_deletion: true).order(id: :desc).first&.reason&.downcase, - width: image_width, - height: image_height, - mpixels: image_width && image_height ? (image_width.to_f * image_height / 1_000_000).round(2) : 0.0, - aspect_ratio: image_width && image_height ? (image_width.to_f / [image_height, 1].max).round(2) : 1.0, - duration: duration, + comment_count: options[:comment_count] || comment_count, + file_size: file_size, + parent: parent_id, + pools: options[:pools] || ::Pool.where("? = ANY(post_ids)", id).pluck(:id), + sets: options[:sets] || ::PostSet.where("? = ANY(post_ids)", id).pluck(:id), + commenters: options[:commenters] || ::Comment.undeleted.where(post_id: id).pluck(:creator_id), + noters: options[:noters] || ::Note.active.where(post_id: id).pluck(:creator_id), + faves: options[:faves] || ::Favorite.where(post_id: id).pluck(:user_id), + upvotes: options[:upvotes] || ::PostVote.where(post_id: id).where("score > 0").pluck(:user_id), + downvotes: options[:downvotes] || ::PostVote.where(post_id: id).where("score < 0").pluck(:user_id), + children: options[:children] || ::Post.where(parent_id: id).pluck(:id), + notes: options[:notes] || ::Note.active.where(post_id: id).pluck(:body), + uploader: uploader_id, + approver: approver_id, + deleter: options[:deleter] || ::PostFlag.where(post_id: id, is_resolved: false, is_deletion: true).order(id: :desc).first&.creator_id, + del_reason: options[:del_reason] || ::PostFlag.where(post_id: id, is_resolved: false, is_deletion: true).order(id: :desc).first&.reason&.downcase, + width: image_width, + height: image_height, + mpixels: image_width && image_height ? (image_width.to_f * image_height / 1_000_000).round(2) : 0.0, + aspect_ratio: image_width && image_height ? (image_width.to_f / [image_height, 1].max).round(2) : 1.0, + duration: duration, tags: tag_string.split(" "), md5: md5, diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 1d30f168f..b04f50f6d 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -47,6 +47,7 @@ export { default as PostReplacement } from '../src/javascripts/post_replacement. export { default as PostVersions } from '../src/javascripts/post_versions.js'; export { default as Replacer } from '../src/javascripts/replacer.js'; export { default as Shortcuts } from '../src/javascripts/shortcuts.js'; +export { default as StaffNote } from '../src/javascripts/staff_notes.js'; export { default as Utility } from '../src/javascripts/utility.js'; export { default as TagRelationships } from '../src/javascripts/tag_relationships.js'; export { default as Takedown } from '../src/javascripts/takedowns.js'; diff --git a/app/javascript/src/javascripts/password.js b/app/javascript/src/javascripts/password.js new file mode 100644 index 000000000..158ee56a8 --- /dev/null +++ b/app/javascript/src/javascripts/password.js @@ -0,0 +1,70 @@ +import zxcvbn from "zxcvbn"; +import Page from "./utility/page"; + +let Password = {}; + +Password.init_validation = function () { + if (Page.matches("users", "new") || Page.matches("users", "create")) + Password.bootstrap_input($("#user_password"), [$("#user_name"), $("#user_email")]); + + if (Page.matches("maintenance-user-password-resets", "edit")) + Password.bootstrap_input($("#password")); + + if (Page.matches("maintenance-user-passwords", "edit")) + Password.bootstrap_input($("#user_password")); +}; + +Password.bootstrap_input = function ($password, $inputs = []) { + // Set up the UI + $password.parent().addClass("password-input"); + + const hint = $("
") + .addClass("password-feedback") + .insertAfter($password); + const display = $("
") + .addClass("password-strength") + .insertAfter($password); + const progress = $("
") + .addClass("password-progress") + .css("width", 0) + .appendTo(display); + + // Listen to secondary input changes + let extraData = getExtraData(); + for (const one of $inputs) + one.on("input", () => { + extraData = getExtraData(); + }); + + // Listen to main input changes + $password.on("input", () => { + const analysis = zxcvbn($password.val() + "", extraData); + + progress.css("width", ((analysis.score * 25) + 10) + "%"); + hint.html(""); + if (analysis.feedback.warning) + $("") + .text(analysis.feedback.warning) + .addClass("password-warning") + .appendTo(hint); + for (const one of analysis.feedback.suggestions) + $("") + .text(one) + .appendTo(hint); + }); + + function getExtraData () { + const output = []; + for (const one of $inputs) { + const val = one.val() + ""; + if (val) output.push(one.val() + ""); + } + return output; + } +}; + +$(() => { + Password.init_validation(); +}); + +export default Password; diff --git a/app/javascript/src/javascripts/staff_notes.js b/app/javascript/src/javascripts/staff_notes.js new file mode 100644 index 000000000..2122ad53b --- /dev/null +++ b/app/javascript/src/javascripts/staff_notes.js @@ -0,0 +1,25 @@ +const StaffNote = {}; + +StaffNote.initialize_all = function () { + $(".expand-new-staff-note").on("click", StaffNote.show_new_note_form); + $(".edit-staff-note-link").on("click", StaffNote.show_edit_form); +}; + +StaffNote.show_new_note_form = function (e) { + e.preventDefault(); + $(e.target).hide(); + var $form = $(e.target).closest("div.new-staff-note").find("form"); + $form.show(); + $form[0].scrollIntoView(false); +}; + +StaffNote.show_edit_form = function (e) { + e.preventDefault(); + $(this).closest(".staff-note").find(".edit_staff_note").show(); +}; + +$(document).ready(function () { + StaffNote.initialize_all(); +}); + +export default StaffNote; diff --git a/app/javascript/src/javascripts/tag_editor.vue b/app/javascript/src/javascripts/tag_editor.vue index 701d97f22..fda069471 100644 --- a/app/javascript/src/javascripts/tag_editor.vue +++ b/app/javascript/src/javascripts/tag_editor.vue @@ -11,6 +11,7 @@ Related: Tags | Artists | + Contributors | Copyrights | Characters | People | diff --git a/app/javascript/src/javascripts/uploader/uploader.vue.erb b/app/javascript/src/javascripts/uploader/uploader.vue.erb index 29ad91546..204a5d5c9 100644 --- a/app/javascript/src/javascripts/uploader/uploader.vue.erb +++ b/app/javascript/src/javascripts/uploader/uploader.vue.erb @@ -28,7 +28,7 @@