text:
# frozen_string_literal: true

class DomainGroupsController < ApplicationController
  before_action :set_group, only: %i[show edit update destroy emails]
  before_action :authenticate_user!, only: %i[new create edit update destroy emails]
  before_action :verify_core, only: %i[new create edit update]
  before_action :verify_admin, only: :destroy

  def index
    @groups = DomainGroup.joins('LEFT JOIN domain_groups_spam_domains j ON domain_groups.id = j.domain_group_id')
                         .select(Arel.sql('domain_groups.*, COUNT(j.spam_domain_id) as domain_count'))
                         .group(Arel.sql('domain_groups.id')).order(:name).paginate(page: params[:page], per_page: 50)
  end

  def new
    @group = DomainGroup.new
  end

  def create
    @group = DomainGroup.new group_params
    unless valid_regex? @group.regex
      flash[:danger] = "Invalid regex `#{@group.regex}`."
      render :new
      return
    end

    if @group.save
      regex = Regexp.new @group.regex
      domains = SpamDomain.all.reject { |d| regex.match(d.domain).nil? }
      @group.spam_domains += domains
      redirect_to domain_group_path(@group)
    else
      render :new
    end
  end

  def show
    @domains = @group.spam_domains.includes(:domain_tags).paginate(page: params[:page], per_page: 50)
  end

  def edit; end

  def update
    unless group_params[:regex] && valid_regex?(group_params[:regex])
      flash[:danger] = "Invalid regex `#{group_params[:regex]}`."
      render :edit
      return
    end

    if @group.update group_params
      redirect_to domain_group_path(@group)
    else
      render :edit
    end
  end

  def destroy
    if @group.destroy
      redirect_to domain_groups_path
    else
      flash[:danger] = 'Failed to remove the domain group. Tell Undo.'
      render :show
    end
  end

  def emails
    success = Emails::Preference.sign_up_for_emails(
      email_address: params[:addressee_email],
      name: params[:addressee_name],
      type_short: 'domain-group-summary',
      frequency_days: params[:frequency],
      reference: @group,
      consent_via: 'admin',
      consent_comment: "admin user.id = #{current_user.id}\ncomment: #{params[:consent_comment]}"
    )
    if success
      flash[:success] = 'Created subscription.'
    else
      flash[:danger] = 'Failed to create subscription. Poke somebody.'
    end
    redirect_back fallback_location: domain_group_path(@group)
  end

  private

  def valid_regex?(regex)
    Regexp.new(regex) if regex.present?

    true
  rescue RegexpError
    false
  end

  def group_params
    params.require(:domain_group).permit(:name, :regex)
  end

  def set_group
    @group = DomainGroup.find params[:id]
  end
end


text:
# frozen_string_literal: true

class FeedbacksController < ApplicationController
  before_action :authenticate_user!, except: [:create]
  before_action :set_feedback, only: %i[show edit update destroy]
  before_action :check_if_smokedetector, only: :create

  protect_from_forgery except: [:create]

  def clear
    @post = Post.find(params[:id])
    @feedbacks = Feedback.unscoped.where(post_id: params[:id])

    unless verify_access(@feedbacks)
      redirect_to missing_privileges_path(required: :admin)
      return
    end

    @sites = [@post.site]

    raise ActionController::RoutingError, 'Not Found' if @post.nil?
  end

  def delete
    f = Feedback.find params[:id]

    raise ActionController::RoutingError, 'Not Found' unless verify_access(f)

    if f.user == current_user && f.created_at.present? && f.created_at >= 10.minutes.ago
      f.destroy
    else
      f.update(is_invalidated: true, invalidated_by: current_user, invalidated_at: DateTime.now)
    end

    f.post.update_feedback_cache

    if verify_access(Feedback.unscoped.where(post_id: f.post_id))
      redirect_to clear_post_feedback_path(f.post_id)
    else
      redirect_to post_path(id: f.post_id)
    end
  end

  # POST /feedbacks
  # POST /feedbacks.json
  def create
    # puts Feedback.where(post_id: params[:feedback][:post_id], user_name: params[:feedback][:user_name]).present?
    post_link = feedback_params[:post_link]

    post = Post.where(link: post_link).order(:created_at).last

    render(plain: 'Error: No post found for link') && return if post.nil?

    # Ignore identical feedback from the same user
    if Feedback.where(post: post, user_name: feedback_params[:user_name], feedback_type: feedback_params[:feedback_type]).present?
      render(plain: 'Identical feedback from user already exists on post') && return
    end

    @feedback = Feedback.new(feedback_params)

    post.reasons.each do |reason|
      expire_fragment(reason)
    end

    expire_fragment('post' + post.id.to_s)

    @feedback.post = post

    if Feedback.where(chat_user_id: @feedback.chat_user_id).count == 0
      feedback_intro = 'It seems this is your first time sending feedback to SmokeDetector. ' \
      'Make sure you\'ve read the guidance on [your privileges](https://git.io/voC8N), ' \
      'the [available commands](https://git.io/voC4m), and [what feedback to use in different situations](https://git.io/voC4s).'
      ActionCable.server.broadcast 'smokedetector_messages', message: "@#{@feedback.user_name.delete(' ')}: #{feedback_intro}"
    end

    respond_to do |format|
      if @feedback.save
        format.json { render :show, status: :created, body: 'OK' }
      else
        format.json { render json: @feedback.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_feedback
    @feedback = Feedback.find(params[:id])
  end

  # Never trust parameters from the scary internet, only allow the white list through.
  def feedback_params
    params.require(:feedback).permit(:message_link, :user_name, :user_link, :feedback_type, :post_link, :chat_user_id, :chat_host)
  end

  def verify_access(feedbacks)
    return true if current_user.has_role? :admin
    if feedbacks.respond_to? :where
      return false unless feedbacks.where(user_id: current_user.id).exists?
    else
      return false unless feedbacks.user_id == current_user.id
    end
    true
  end
end

repo_name: Charcoal-SE/metasmoke

repo_language: Ruby

mime_type: text/plain


text:
# frozen_string_literal: true

class DeveloperController < ApplicationController
  before_action :authenticate_user!, except: [:blank_page]
  before_action :verify_developer, except: %i[blank_page change_back verify_elevation]
  before_action :check_impersonating, only: %i[change_back verify_elevation]

  def update_sites
    SitesHelper.update_sites
    flash[:info] = 'Site cache updated, refer to MiniProfiler for execution details.'
    redirect_back(fallback_location: url_for(controller: :dashboard, action: :index))
  end

  def production_log
    @log = `tail -n 1000 log/production.log`
    @log = clean_log_formatting(@log)
  end

  def production_log_search
    @log = if params[:grep].present?
             unless params[:context].respond_to?(:to_i)
               flash[:warning] = "Coercion failure on params[:context]: Can't convert to an interger"
               redirect_to(root_path) && return
             end
             `tail -n 10000 log/production.log | grep -E '#{params[:grep].to_s.tr("'", '')}' -C '#{params[:context].to_i}' --color=never`
           else
             `tail -n 1000 log/production.log`
           end

    @logs = clean_log_formatting(@log)
    render action: :production_log
  end

  def make_space_in_logs
    redis(logger: true).zrange('requests', 0, -1, with_scores: true).each do |val, score|
      new_expire = REDIS_LOG_EXPIRATION.to_i - (Time.now.to_i - score.to_i)
      keys = redis(logger: true).scan_each(match: "request/#{score}*").to_a
      redis(logger: true).multi do
        if new_expire <= 0
          keys.each { |k| redis(logger: true).del(k) }
          redis(logger: true).zrem 'requests', val
        else
          keys.each do |k|
            redis(logger: true).expire k, new_expire
          end
        end
      end
    end
  end

  def deploy
    message = "[ [metasmoke-deploy](//github.com/Undo1/metasmoke-deploy/actions/workflows/deploy.yml) ] deploy started by #{current_user.username}"
    SmokeDetector.send_message_to_charcoal(message)

    Octokit.workflow_dispatch('Undo1/metasmoke-deploy', 'deploy.yml', 'master')

    redirect_to 'https://github.com/Undo1/metasmoke-deploy/actions/workflows/deploy.yml'
  end

  def query_times_log
    send_file 'log/query_times.log'
  end

  def blank_page
    render layout: false
  end

  def empty_layout
    render layout: true
  end

  def websocket_test; end

  def send_websocket_test
    ActionCable.server.broadcast params[:channel], JSON.parse(params[:content])
    flash[:info] = 'Queued for broadcast.'
    redirect_to url_for(controller: :developer, action: :websocket_test)
  end

  def change_users
    dev_id = current_user.id
    @user = User.find params[:id]
    sign_in @user
    session[:impersonator_id] = dev_id
    flash[:success] = "You are now impersonating #{@user.username}."
    redirect_to root_path
  end

  def change_back
    @impersonator = User.find session[:impersonator_id]
  end

  def verify_elevation
    @impersonator = User.find session[:impersonator_id]
    if @impersonator&.valid_password? params[:password]
      session.delete :impersonator_id
      sign_in @impersonator
      redirect_to root_path
    else
      flash[:danger] = 'Incorrect password.'
      render :change_back
    end
  end

  def run_fcrs
    ConflictingFeedbackJob.perform_later
    flash[:success] = 'ConflictingFeedbackJob triggered.'
    redirect_back fallback_location: root_path
  end

  def run_feedback_reindex
    FeedbackReindexJob.perform_later
    flash[:success] = 'FeedbackReindexJob triggered.'
    redirect_back fallback_location: root_path
  end

  private

  def check_impersonating
    require_developer unless session[:impersonator_id].present?
  end

  def clean_log_formatting(logs)
    logs.gsub(/\e\[([;\d]+)?m/, '') # Terminal color codes
    logs.gsub(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/, '') # IPs
    logs.gsub(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}\b/i, '') # Emails
    logs
  end
end


text:
# frozen_string_literal: true

class GraphsController < ApplicationController
  def index; end

  def reports_by_hour
    data = cached_query :reports_by_hour_graph do
      [
        { name: 'All', data: Post.group_by_hour(:created_at, range: 1.week.ago.to_date..Time.now).count },
        { name: 'True positives', data: Post.where(is_tp: true).group_by_hour(:created_at, range: 1.week.ago.to_date..Time.now).count },
        { name: 'False positives', data: Post.where(is_fp: true).group_by_hour(:created_at, range: 1.week.ago.to_date..Time.now).count }
      ]
    end
    render json: data
  end

  def reason_counts
    render json: Reason.joins(:posts)
      .where('posts.created_at >= ?', (params[:months] || 3).to_i.months.ago)
      .where(params[:site_id].present? ? { posts: { site_id: params[:site_id] } } : {})
      .group(:reason_name)
      .count
      .sort_by(&:last)
  end

  def reports_by_site
    @posts = if params[:timeframe] == 'all'
               Post.all
             else
               Post.where('created_at >= ?', 1.month.ago)
             end
    h = HTMLEntities.new

    render json: @posts.group(:site)
                       .count
                       .map { |k, v| { (k.nil? ? 'Unknown' : h.decode(k.site_name)) => v } }
                       .reduce(:merge)
                       .reject { |k, _v| k == 'Unknown' }
                       .sort_by { |_k, v| v }.reverse
  end

  def reports_by_hour_of_day
    @posts = if params[:timeframe] == 'all'
               Post.all
             else
               Post.where('created_at >= ?', 1.month.ago)
             end

    number_of_days = (DateTime.now - @posts.minimum(:created_at).to_date).to_i
    tp_posts = @posts.where(is_tp: true).group_by_hour_of_day(:created_at).count.map { |k, v| [k, (v.to_f / number_of_days).round(2)] }.to_h
    fp_posts = @posts.where(is_fp: true).group_by_hour_of_day(:created_at).count.map { |k, v| [k, (v.to_f / number_of_days).round(2)] }.to_h

    render json: [
      { name: 'All', data: @posts.group_by_hour_of_day(:created_at).count.map { |k, v| [k, (v.to_f / number_of_days).round(2)] }.to_h },
      { name: 'True positives', data: tp_posts },
      { name: 'False positives', data: fp_posts }
    ]
  end

  def time_to_deletion
    data = cached_query :time_to_deletion_graph, expires_in: 1.day, always_cache: true do
      Post.group_by_hour_of_day(:created_at).where(is_tp: true)
          .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`) <= 3600')
          .where.not(deleted_at: nil)
          .average('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`)')
    end
    render json: data
  end

  def flagging_results
    if params[:months].present? || params[:site_id].present?
      @flags = FlagLog.auto
      @flags = @flags.where('flag_logs.created_at > ?', (params[:months] || 3).to_i.months.ago) if params[:months].present?
      @flags = @flags.where(site_id: params[:site_id]) if params[:site_id].present?
      data = [
        ['Fail', @flags.where(success: false).count],
        ['Dry run', @flags.where(success: true, is_dry_run: true).count],
        ['Success (tp)', @flags.where(success: true, is_dry_run: false).tp.count],
        ['Success (fp)', @flags.where(success: true, is_dry_run: false).fp.count]
      ]
    else
      data = cached_query :flagging_results_graph do
        [
          ['Fail', FlagLog.auto.where(success: false).count],
          ['Dry run', FlagLog.auto.where(success: true, is_dry_run: true).count],
          ['Success', FlagLog.auto.where(success: true, is_dry_run: false).count]
        ]
      end
    end
    render json: data
  end

  def flagging_timeline
    data = cached_query :flagging_timeline_graph do
      [
        {
          name: 'Failures',
          data: FlagLog.auto.where(success: false).group_by_day(:created_at, range: 1.month.ago.to_date..Time.now).count
        },
        {
          name: 'Dry runs',
          visible: false,
          data: FlagLog.auto.where(success: true, is_dry_run: true).group_by_day(:created_at, range: 1.month.ago.to_date..Time.now).count
        },
        {
          name: 'Successes',
          data: FlagLog.auto.where(success: true, is_dry_run: false).group_by_day(:created_at, range: 1.month.ago.to_date..Time.now).count
        }
      ]
    end
    render json: data
  end

  def detailed_ttd
    @posts = params[:site_id].present? ? Post.where(site_id: params[:site_id]) : Post.all
    no_flags = @posts.group_by_hour_of_day('`posts`.`created_at`')
                     .select(Arel.sql('AVG(TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`)) as time_to_deletion'))
                     .joins(:deletion_logs)
                     .where(is_tp: true)
                     .where('`posts`.`created_at` < ?', Date.new(2017, 1, 1))
                     .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`) <= 3600').relation.each_with_index
                     .map { |a, i| [i, a.time_to_deletion.round(0)] }
    one_flag = @posts.group_by_hour_of_day('`posts`.`created_at`')
                     .select(Arel.sql('AVG(TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`)) as time_to_deletion'))
                     .joins(:deletion_logs)
                     .where(is_tp: true)
                     .where('`posts`.`created_at` >= ?', Date.new(2017, 1, 1))
                     .where('`posts`.`created_at` < ?', Date.new(2017, 2, 14))
                     .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`) <= 3600').relation.each_with_index
                     .map { |a, i| [i, a.time_to_deletion.round(0)] }
    three_flags = @posts.group_by_hour_of_day('`posts`.`created_at`')
                        .select(Arel.sql('AVG(TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`)) as time_to_deletion'))
                        .joins(:deletion_logs)
                        .where(is_tp: true)
                        .where('`posts`.`created_at` >= ?', Date.new(2017, 2, 14))
                        .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `deletion_logs`.`created_at`) <= 3600').relation.each_with_index
                        .map { |a, i| [i, a.time_to_deletion.round(0)] }
    render json: [
      { name: '0 flags', data: no_flags },
      { name: '1 flag', data: one_flag },
      { name: '3 flags', data: three_flags }
    ]
  end

  def monthly_ttd
    data = if params[:site_id].present?
             Post.where(site_id: params[:site_id]).group_by_day('`posts`.`created_at`').joins(:deletion_logs)
                 .where(is_tp: true).where('`posts`.`created_at` > ?', (params[:months].try(:to_i) || 3).months.ago.to_date)
                 .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`) <= 3600')
                 .average('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`)')
                 .to_h
           else
             cached_query :monthly_ttd_graph do
               Post.group_by_day('`posts`.`created_at`').joins(:deletion_logs)
                   .where(is_tp: true).where('`posts`.`created_at` > ?', (params[:months].try(:to_i) || 3).months.ago.to_date)
                   .where('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`) <= 3600')
                   .average('TIMESTAMPDIFF(SECOND, `posts`.`created_at`, `posts`.`deleted_at`)')
                   .to_h
             end
           end
    data = data.map { |k, v| [k, v.round(params[:round].to_i)] } if params[:round]
    render json: data
  end

  def reports
    @posts = Post.where('created_at > ?', (params[:months] || 3).to_i.months.ago)
    @posts = @posts.where(site_id: params[:site_id]) if params[:site_id].present?
    render json: [
      { name: 'All', data: @posts.group_by_day(:created_at).count },
      { name: 'TP', data: @posts.where(is_tp: true).group_by_day(:created_at).count },
      { name: 'FP', data: @posts.where(is_fp: true).group_by_day(:created_at).count }
    ]
  end

  def report_counts
    @posts = Post.where('created_at > ?', (params[:months] || 3).to_i.months.ago)
    @posts = @posts.where(site_id: params[:site_id]) if params[:site_id].present?
    render json: [
      ['NAA', @posts.where(is_naa: true).count],
      ['TP', @posts.where(is_tp: true).count],
      ['FP', @posts.where(is_fp: true).count]
    ]
  end

  def af_accuracy
    data = Rails.cache.fetch :af_detail_data, expires_in: 1.hour do
      total_query = ActiveRecord::Base.connection.execute File.read(Rails.root.join('lib/queries/post_counter.sql'))
      tp_query = ActiveRecord::Base.connection.execute File.read(Rails.root.join('lib/queries/tp_counter.sql'))
      fp_query = ActiveRecord::Base.connection.execute File.read(Rails.root.join('lib/queries/fp_counter.sql'))

      total_counts = total_query.map(&:last)
      totals = total_query.map.with_index { |e, i| [e[0], total_counts[i..-1].sum] }.select { |x| x[0] <= 400 }

      tp_counts = tp_query.map(&:last)
      tps = tp_query.map.with_index { |e, i| [e[0], tp_counts[i..-1].sum] }.select { |x| x[0] <= 400 }

      fp_counts = fp_query.map(&:last)
      fps = fp_query.map.with_index { |e, i| [e[0], fp_counts[i..-1].sum] }.select { |x| x[0] <= 400 }

      { totals: totals, tps: tps, fps: fps }
    end

    maxlen = [data[:totals].size, data[:tps].size, data[:fps].size].max - 1
    range = 0..maxlen
    # BEHOLD. MAGIC.
    # Okay fine. Calculates accuracy for each weight 10-group, providing a value (0) for fps if it's not there, and clamping to 0.9..1.0.
    acc_data = range.map do |i|
      [data[:tps][i][0], ([data[:tps][i][1].to_f / (data[:tps][i][1] + (data[:fps][i] || [0, 0])[1]), 0.90].max * 100).round(2)]
    end

    render json: [
      { name: 'Total posts', data: data[:totals] },
      { name: 'TPs', data: data[:tps] },
      { name: 'Accuracy', data: acc_data }
    ]
  end

  def qtimes; end # stub, temp, delete me when redis log graphs are done

  def query_times_graphs
    controller = params[:controller_name]
    action = params[:action_name]
    data = redis(logger: true).zrangebyscore("request_timings/total/by_action/#{controller}##{action}",
                                             1.month.ago.to_i, '+inf',
                                             with_scores: true)
                              .map { |y, x| [x.to_f, y.to_f] }
    throw 'No Data' if data.nil? || data.empty?
    smoothed = moving_avg(data)
    final = (params[:raw].present? ? data : smoothed).map { |x, y| [Time.at(x.to_i).to_datetime, y] }
    render json: [
      { name: 'Timings', data: final }
    ]
  end

  private

  def cached_query(cache_key, **opts)
    opts[:expires_in] ||= 1.hour
    opts[:param_name] ||= :cache
    opts[:always_cache] ||= false

    if opts[:always_cache] || params[opts[:param_name]].present?
      Rails.cache.fetch cache_key, expires_in: opts[:expires_in] do
        yield
      end
    else
      yield
    end
  end

  def moving_avg(pts)
    yv = pts.map(&:last)
    ad = yv.map.with_index { |_v, i| yv[([0, i - 3].max)..([i + 3, yv.size].min)].sum / (([0, i - 3].max)..([i + 3, yv.size].min)).size.to_f }
    pts.map.with_index { |e, i| [e[0], ad[i]] }
  end
end


text:
# frozen_string_literal: true

class DomainTagsController < ApplicationController
  before_action :authenticate_user!, only: %i[add remove edit update destroy add_post remove_post submit_mass_tag add_review]
  before_action :verify_core, only: %i[add remove edit update add_post remove_post submit_mass_tag add_review]
  before_action :verify_admin, only: [:destroy]
  before_action :set_domain_tag, only: %i[show edit update destroy]
  before_action :verify_developer, only: [:merge]

  def index
    @tags = if params[:filter].present? && params[:include_special].present?
              DomainTag.all.where('name LIKE ?', "%#{params[:filter]}%")
            elsif params[:filter].present?
              DomainTag.standard.where('name LIKE ?', "%#{params[:filter]}%")
            else
              DomainTag.standard
            end.order(name: :asc).paginate(page: params[:page], per_page: 100)
    @counts = DomainTag.where(id: @tags.map(&:id)).joins(:spam_domains).group(Arel.sql('domain_tags.id')).count
  end

  def add
    @tag = DomainTag.find_or_create_by name: params[:tag_name]
    @domain = SpamDomain.find params[:domain_id]
    if @domain.domain_tags.include? @tag
      flash[:danger] = "This domain already has the tag '#{@tag.name}'."
    else
      @domain.domain_tags << @tag
    end
    redirect_to spam_domain_path(@domain)
  end

  def add_review
    @tag = DomainTag.find_or_create_by name: params[:tag_name]
    @domain = SpamDomain.find params[:domain_id]
    @domain.domain_tags << @tag unless @domain.domain_tags.include? @tag
    render 'domain_tags/_tag', locals: { tag: @tag, domain: @domain }, layout: nil
  end

  def add_post
    @tag = DomainTag.find_or_create_by name: params[:tag_name]
    @post = Post.find params[:post_id]
    if @post.post_tags.include? @tag
      flash[:danger] = "This post already has the tag '#{@tag.name}'."
    else
      @post.post_tags << @tag
    end
    redirect_to post_path(@post)
  end

  def remove
    @domain = SpamDomain.find params[:domain_id]
    @domain.domain_tags.delete(DomainTag.find_by(name: params[:tag_name]))
    redirect_to spam_domain_path(@domain)
  end

  def remove_post
    @post = Post.find params[:post_id]
    @post.post_tags.delete(DomainTag.find_by(name: params[:tag_name]))
    redirect_back fallback_location: post_path(@post)
  end

  def show
    if params[:what] == 'posts'
      @posts = @tag.posts.includes_for_post_row.paginate(page: params[:page], per_page: 100)
      @sites = Site.where(id: @posts.map(&:site_id))
    else
      @counts_summary = %i[all tp fp naa].map do |t|
        [t, Post.joins(spam_domains: :domain_tags).where(domain_tags: { id: @tag.id }).distinct.send(t).count]
      end.to_h
      @domains = @tag.spam_domains.paginate(page: params[:page], per_page: 100)
      @counts = SpamDomain.where(id: @domains.map(&:id)).joins(:posts).group(Arel.sql('spam_domains.id')).count
      @counts_per_domain = %i[tp fp naa].map do |t|
        [t, @tag.spam_domains.joins(:posts).where(posts: { "is_#{t}": true }).group('spam_domains.id').count]
      end.to_h
    end
  end

  def edit; end

  def update
    if @tag.update tag_params
      flash[:success] = 'Updated tag.'
      redirect_to domain_tag_path(@tag)
    else
      flash[:danger] = 'Failed to update tag.'
      render :edit
    end
  end

  def destroy
    if @tag.destroy
      flash[:success] = 'Removed tag.'
      redirect_to domain_tags_path
    else
      flash[:danger] = 'Failed to remove tag.'
      render :show
    end
  end

  def mass_tagging
    @domains = if params[:filter].present?
                 mass_tag_filter params[:filter]
               else
                 SpamDomain.all
               end.paginate(page: params[:page], per_page: 100)
    @counts = SpamDomain.where(id: @domains.map(&:id)).joins(:posts).group(Arel.sql('spam_domains.id')).count
    @taggable = params[:filter].present?
  end

  def submit_mass_tag
    @domains = mass_tag_filter params[:filter]
    @tag = DomainTag.find_or_create_by name: params[:tag]
    pairs = @domains.map { |d| [@tag, d] }
    ApplicationRecord.mass_habtm 'domain_tags_spam_domains', 'domain_tag', 'spam_domain', pairs
    flash[:success] = 'Tag applied to selected posts.'
    redirect_to domain_tags_mass_tagging_path(filter: params[:filter])
  end

  def merge
    source = DomainTag.find params[:source_id]
    target = DomainTag.find params[:target_id]

    source.merge_into target

    flash[:success] = 'Tag merge completed.'
    redirect_to domain_tag_path(target)
  end

  private

  def set_domain_tag
    @tag = DomainTag.find params[:id]
  end

  def tag_params
    params.require(:domain_tag).permit(:name, :description)
  end

  def mass_tag_filter(filter_param)
    SpamDomain.where('domain LIKE ?', ApplicationRecord.sanitize_like(filter_param).tr('*', '%'))
  end
end


text:
# frozen_string_literal: true

class DashboardController < ApplicationController
  before_action :verify_core, only: %i[db_dumps download_dump]
  before_action :verify_developer, only: [:reset_query_time]

  def funride
    response.cache_control = 'public, max-age=86400'
    redirect_to 'https://speed.hetzner.de/10GB.bin', status: :moved_permanently
  end

  def index
    if params[:id].present?
      redirect_to '/magic/funride', status: :moved_permanently
      return
    end

    Rack::MiniProfiler.step('Redis queries') do
      @inactive_reasons, @active_reasons = # Rails.cache.fetch 'reasons_index', expires_in: 6.hours do
        [true, false].map do |inactive|
          results = Reason.where(inactive: inactive).to_a.map do |reason|
            reason.define_singleton_method(:post_count) { Redis::Reason.find(reason.id).count }
            reason
          end.sort_by(&:post_count).reverse
          counts = results.map do |reason|
            reason = Redis::Reason.find(reason.id)
            per_feedback_counts = %w[tps fps naas].map do |fb|
              reason.clear_reason_feedback_cache(fb)
              reason.update_reason_feedback_cache(fb)
              [fb[0..-2].to_sym, reason.for_feedback(fb).cardinality]
            end.to_h
            [reason.id, { total: reason.count }.merge(per_feedback_counts)]
          end.to_h

          { counts: counts, results: results }
        end
    end

    @reasons = Reason.all
    @posts = Post.all
  end

  def new_dash; end

  def spam_by_site
    @posts = Post.includes_for_post_row

    @posts = @posts.where(site_id: params[:site]) if params[:site].present?

    @posts = @posts.undeleted if params[:undeleted].present?

    @posts = @posts.order(id: :desc).paginate(per_page: 50, page: params[:page])
    @sites = Site.where(id: @posts.map(&:site_id))
  end

  def query_times
    @query_times = redis(logger: true).smembers('request_timings/path_strings')
                                      .map     { |path_str| Redis::QueryAverage.new(*path_str.split('/', 2), since: params[:since].to_i) }
                                      .sort_by { |h|        -h.average(:total) }
  end

  def reset_query_time
    if !redis(logger: true).sismember('request_timings/path_strings', params[:path])
      flash[:warning] = 'Invalid path to reset'
    else
      Redis::QueryAverage.new(*params[:path].split('/', 2)).reset
    end
    redirect_back fallback_location: root_path
  end

  def site_dash
    @posts = Post.includes_for_post_row.includes(:flag_logs)
    params[:site_id] = Site.first.id if params[:site_id].blank?
    @site = Site.find(params[:site_id])

    @months = params[:months].to_s.empty? ? 3 : params[:months].to_i
    @months = 1 if @months < 1
    @months_string = @months == 1 ? 'month' : "#{@months} months"

    @all_posts = @posts.where(site_id: @site.id)

    @tabs = {
      'All' => @all_posts,
      'Autoflagged' => @all_posts.where(autoflagged: true),
      'Deleted' => @all_posts.where.not(deleted_at: nil),
      'Undeleted' => @all_posts.where(deleted_at: nil)
    }
    special_tabs = %w[Spammers Autoflaggers]
    @active_tab = (@tabs.keys + special_tabs).map(&:downcase).include?(params[:tab]&.downcase) ? params[:tab]&.downcase : 'all'

    @posts = @tabs.map { |k, v| [k.downcase, v] }.to_h[params[:tab]&.downcase] || @tabs['All']

    @flags = FlagLog.where(site: @site).where('`flag_logs`.`created_at` >= ?', @months.months.ago).auto

    @spammers = StackExchangeUser.joins(:feedbacks).includes(:posts).where(site: @site, still_alive: true)
                                 .where("feedbacks.feedback_type LIKE 't%'").group('stack_exchange_users.id')
                                 .order('COUNT(posts.stack_exchange_user_id) DESC')
    # .order(Arel.sql('stack_exchange_users.reputation DESC'))

    @spammers_page = @spammers.paginate(per_page: 50, page: params[:page])

    @autoflaggers = User.joins(:flag_logs, flag_logs: [:post])
                        .where(flag_logs: { site: @site, success: true, is_auto: true })
                        .group(Arel.sql('users.id'))
                        .order(Arel.sql('COUNT(DISTINCT flag_logs.id) DESC'))
                        .select(Arel.sql('users.stack_exchange_account_id, users.username, COUNT(DISTINCT flag_logs.id) AS total_flags,'\
                                         'COUNT(DISTINCT IF(posts.is_tp = 1, flag_logs.id, NULL)) AS tp_flags'))

    @autoflaggers_page = @autoflaggers.paginate(per_page: 50, page: params[:page])

    @delimiter = "\u2005".encode('utf-8')

    @posts_timescaled = @posts.where('posts.created_at >= ?', @months.months.ago)
    @posts = @posts.order(id: :desc).paginate(per_page: 50, page: params[:page])
  end
end


text:
# frozen_string_literal: true

class RSSController < ApplicationController
  def deleted
    # Wait 10 minutes to see if it's been deleted
    @posts = Post.all
    @posts = @posts.where('created_at < ?', 10.minutes.ago) unless params[:nowait]
    @posts = @posts.order('created_at DESC').paginate(page: params[:page], per_page: 50)
    @posts = @posts.where(site: params[:site]) if params[:site].present?

    @tags = []

    # If 'deleted' is passed, check if 'true'. If it's not passed, treat it as true.
    @posts = if params[:deleted].present?
               case params[:deleted].downcase
               when 'true'
                 @posts.where.not(deleted_at: nil)
               when 'any'
                 @posts
               else
                 @posts.where(deleted_at: nil)
               end
             else
               @posts.where.not(deleted_at: nil)
             end

    # If 'autoflagged' is passed, check if 'true'. If it's not passed, treat it as true.
    @posts = if params[:autoflagged].present?
               case params[:autoflagged].downcase
               when 'true'
                 @posts.where(autoflagged: params[:autoflagged])
               when 'any'
                 @posts
               else
                 @posts.where(autoflagged: true)
               end
             else
               @posts.where(autoflagged: true)
             end

    if params[:from_date].present?
      @posts = @posts.where('`posts`.`created_at` > ?', DateTime.strptime(params[:from_date], '%s'))
    end
    if params[:to_date].present?
      @posts = @posts.where('`posts`.`created_at` < ?', DateTime.strptime(params[:to_date], '%s'))
    end
    if params[:feedback_type].present?
      @posts = @posts.includes(:feedbacks).where(feedbacks: { feedback_type: params[:feedback_type] })
    end
    respond_to do |format|
      format.html
      format.rss { render layout: false }
      format.xml { render 'deleted.rss', layout: false }
    end
  end
end


text:
# frozen_string_literal: true

class GraphqlController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:execute]
  before_action :verify_authorization

  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      # Query context goes here, for example:
      current_user: current_user
    }
    query_params = { variables: variables, context: context, operation_name: operation_name }
    if user_signed_in? && current_user.has_role?(:core)
      # query_params.merge!({max_depth: 8, max_complexity:20})
    end
    @results = MetasmokeSchema.execute(query, **query_params)
    respond_to do |format|
      format.json { render json: @results }
      format.html { @results = JSON.pretty_generate(@results.to_hash) }
    end
  end

  def query; end

  private

  def verify_authorization
    return head 403 unless !APIKey.find_by(key: params[:key]).nil? || (user_signed_in? && current_user.has_role?(:core))
  end

  # Handle form data, JSON body, or a blank value
  def ensure_hash(ambiguous_param)
    case ambiguous_param
    when String
      if ambiguous_param.present?
        ensure_hash(JSON.parse(ambiguous_param))
      else
        {}
      end
    when Hash, ActionController::Parameters
      ambiguous_param
    when nil
      {}
    else
      raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
    end
  end
end


text:
# frozen_string_literal: true

class ReasonsController < ApplicationController
  before_action :verify_core, only: [:update_description]

  def show
    @reason = Reason.find(params[:id])

    # Note: Body nil check as body_exists
    @posts = Redis::Reason.find(params[:id]).intersect('posts', type: :zset)
    # @posts = @reason.posts
    #                 .select(:id, :created_at, :link, :title, :site_id, :username, :stack_exchange_user_id, 'IF(LENGTH(body)>1,1,0) as body_exists')
    #                 .includes(:reasons, :feedbacks)
    #                 .includes(feedbacks: [:user, :api_key])
    #                 .paginate(page: params[:page], per_page: 100)
    #                 .order(Arel.sql('created_at DESC'))

    @total = @posts.cardinality
    @counts_by_feedback = {
      is_tp: @posts.intersect('tps').cardinality,
      is_fp: @posts.intersect('fps').cardinality,
      is_naa: @posts.intersect('naas').cardinality
    }

    case params[:filter]
    when 'tp'
      @posts = @posts.intersect('tps')
    when 'fp'
      @posts = @posts.intersect('fps')
    when 'naa'
      @posts = @posts.intersect('naas')
    end

    @posts = @posts.paginate(params[:page], 100) { |id| Redis::Post.new(id) }

    @sites = Site.where(id: @posts.map(&:site_id)).to_a
  end

  def sites_chart
    h = HTMLEntities.new
    render json: Reason.find(params[:id])
                       .posts
                       .group(:site)
                       .count
                       .map { |k, v| { (k.nil? ? 'Unknown' : h.decode(k.site_name)) => v } }
                       .reduce(:merge)
                       .reject { |k, _v| k == 'Unknown' }
                       .sort_by { |_k, v| v }.reverse
  end

  def accuracy_chart
    @reason = Reason.find(params[:id])
    render json: [
      {
        name: 'True positives',
        data: @reason.posts.where(is_tp: true).group_by_day(:created_at, range: 1.month.ago.to_date..Time.now).count
      },
      {
        name: 'False positives',
        data: @reason.posts.where(is_fp: true).group_by_day(:created_at, range: 1.month.ago.to_date..Time.now).count
      }
    ]
  end

  def update_description
    @reason = Reason.find(params[:id])
    @reason.update(description: params[:description])
    redirect_back fallback_location: reason_path(@reason)
  end
end


text:
# frozen_string_literal: true

class Emails::PreferencesController < ApplicationController
  before_action :set_preference, except: %i[list search]
  before_action :verify_permissions, except: %i[list search]
  before_action :verify_admin, only: [:search]
  skip_before_action :verify_authenticity_token, only: %i[toggle frequency destroy]

  def search
    @addressees = Emails::Addressee.all.order(:name)
  end

  def list
    @types = Emails::Type.all.order(:name)
    @preferences = if params[:token].present?
                     Emails::Preference.joins(:addressee).where(emails_addressees: { manage_key: params[:token] })
                   elsif params[:addressee].present? && current_user&.has_role?(:admin) # only admins are allowed to view prefs for arbitrary users
                     Emails::Preference.where(emails_addressee_id: params[:addressee])
                   elsif params[:email].present? && current_user&.has_role?(:admin) # likewise
                     Emails::Preference.joins(:addressee).where(emails_addressees: { email_address: params[:email] })
                   end.order(Arel.sql('emails_type_id ASC, reference_type ASC, reference_id ASC, created_at ASC'))
                   .group_by(&:emails_type_id)
  end

  def toggle
    @preference.update(enabled: params[:enabled])
    render json: { status: 'Accepted' }, status: 202
  end

  def frequency
    @preference.update(frequency: params[:frequency])
    render json: { status: 'Accepted' }, status: 202
  end

  def destroy
    @preference.destroy
    render json: { status: 'Accepted' }, status: 202
  end

  private

  def set_preference
    @preference = Emails::Preference.find params[:id]
  end

  def verify_permissions
    return if current_user&.has_role?(:admin) || params[:token] == @preference.addressee.manage_key
    redirect_to missing_privileges_path('admin')
  end
end


text:
import { onLoad, route, addLocalSettingsPanel } from './util';

(() => {
  /* Generate post previews
   * Post previews are generated in JavaScript in order to not download images over the
   * network by default.  It also allows a more accurate generation of post contents under
   * most conditions.  However, given what SmokeDetector does with respect to pre-processing
   * the HTML prior to working with it and sending the HTML to metasmoke, there are some,
   * relatively rare, situations where it's impossible to recover the original HTML from
   * what's stored in metasmoke.
   */

  function addPostPreviews() {
    const previewPanels = $('.post-body-panel-preview > .panel > .panel-body:not(.javascript-generated)');
    previewPanels.html(''); // Wipe any existing content
    previewPanels.each(function () {
      const previewBody = $(this);
      const postSiteLink = previewBody.closest('.post-cell, .post-row, body .initial-content').find('.post-site-link');
      const tabsBodyContainer = previewBody.closest('.body-content-container');
      const reportedPostSDPreText = tabsBodyContainer.find('.post-body-panel-text .post-body-pre-block').text();
      const divWithPreview = generatePostBodyDivFromHtmlText(reportedPostSDPreText, false);
      pointRelativeURLsToSourceSESite(divWithPreview, { link: (postSiteLink[0] || { href: 'https://error--getting--site-link.foo/' }).href });
      // Hide images
      const hideImages = localStorage.hidePostPreviewImages !== 'false';
      divWithPreview.find('img').each(function () {
        const image = $(this);
        if (hideImages) {
          image
            .one('click', event => {
              image.attr('src', image.data('src'));
              event.preventDefault();
            });
        }
        else {
          image.attr('src', image.data('src'));
        }
      });
      // Add handlers for spoilers
      divWithPreview.find('blockquote.spoiler')
        .attr('data-spoiler', 'Reveal spoiler')
        .one('click', function () {
          $(this).addClass('is-visible');
        });
      previewBody
        .addClass('javascript-generated')
        .append(divWithPreview);
    });
  }

  onLoad(addPostPreviews);
  $(document).ajaxComplete(() => setTimeout(addPostPreviews, 10));
  $(window).on('MS-review-loaded', addPostPreviews);

  function addHideImagesLocalSetting() {
    const content = $(`
Hide Images in Post Previews

This will prevent both downloading and viewing of inappropriate images in post previews, unless you click to view each image. This is enabled by default.

If enabled, the content of each image in a post preview will initially be a placeholder image. You will be able to click the placeholder image to display the original image. The original images will not be downloaded over the network unless the placeholder is clicked.

If disabled, images in the post preview will always be displayed. They will automatically be downloaded whenever the post preview tab is available, even if the post preview is not displayed.

      <input type="checkbox" name="hideImages" id="hideImages" checked="checked"> Hide images in post preview panels initially with click to show image

`);
    const hideImageInput = content.find('#hideImages');
    hideImageInput.on('change', () => {
      localStorage.hidePostPreviewImages = hideImageInput.is(':checked');
    });
    hideImageInput.prop('checked', localStorage.hidePostPreviewImages !== 'false');
    addLocalSettingsPanel(content);
  }

  route('/users/edit', addHideImagesLocalSetting);

  /* eslint-disable brace-style, capitalized-comments, comma-dangle, object-curly-spacing, arrow-parens */

  // The following code was copied from FIRE (https://github.com/Charcoal-SE/userscripts/blob/master/fire/fire.user.js)
  // by Makyen, the primary author for the copied code. It's under a dual Apache License 2.0 and MIT license.
  // The intent is for the code below and the code in FIRE to remain in sync.

  /**
   * toHTMLEntitiesBetweenTags - Convert HTML tags to  <tag text> that are within the start and end of a specified tag.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {string}    toChange                The complete text to make changes within.
   * @param   {string}    tagText                 The type of tag within which to make changes (e.g. "code")
   * @param   {RegExp}    whiteListedTagsRegex    Falsy or a RegExp that is used to match tags which should be whitelisted inside the changed area.
   *
   * @returns {string}                            The changed text
   */
  function toHTMLEntitiesBetweenTags(toChange, tagText, whiteListedTagsRegex) {
    let codeLevel = 0;
    const tagRegex = new RegExp(`(</?${tagText}>)`, 'g');
    const tagSplit = (toChange || '').split(tagRegex);
    const tagBegin = `<${tagText}>`;
    const tagEnd = `</${tagText}>`;
    return tagSplit.reduce((text, split) => {
      if (split === tagBegin) {
        codeLevel++;
        if (codeLevel === 1) {
          return text + split;
        }
      } else if (split === tagEnd) {
        codeLevel--;
      }
      if (codeLevel > 0) {
        split = split.replace(/</g, '<').replace(/>/g, '>');
        if (whiteListedTagsRegex) {
          whiteListedTagsRegex.lastIndex = 0;
          split = split.replace(whiteListedTagsRegex, '<$1>');
        }
      }
      return text + split;
    }, '');
  }

  // Many of the attributes permitted here are not permitted in Markdown for SE, but are delivered by SE in the HTML for the post body.
  //   SE adds the additional attributes.
  // For , 'rel' is not permitted in Markdown, but is in SE's HTML.
  // The Comprehensive Formatting Test:
  //   https://chat.stackexchange.com/transcript/message/63128026#63128026
  //   https://metasmoke.erwaysoftware.com/posts/by-url?url=//meta.stackexchange.com/a/325826
  //   https://meta.stackexchange.com/a/325826
  const whitelistedTags = {
    withAttributes: {
      blockquote: ['class'], // 'data-spoiler'], // data-spoiler is used on SE sites, but isn't in the HTML delivered by SE.
      div: ['class', 'data-lang', 'data-hide', 'data-console', 'data-babel'],
      ol: ['start'],
      pre: ['class'],
      span: ['class', 'dir'],
      h1: ['id'],
      h2: ['id'],
      h3: ['id'],
      h4: ['id'],
      h5: ['id'],
      h6: ['id'],
    },
    specialCases: {
      a: {
        general: ['title', 'rel', 'alt', 'aria-label', 'aria-labelledby', 'data-onebox-id'],
        specificValues: {
          class: ['post-tag', 'post-tag required-tag', 'post-tag moderator-tag', 'onebox-link'], // example post with tags: https://chat.stackexchange.com/transcript/11540?m=54674140#54674140
          // rel probably has a limited set of values, but that really hasn't been explored, yet.
          // rel: ['tag'], // example post with tags: https://chat.stackexchange.com/transcript/11540?m=54674140#54674140
        },
        // href must be relative, protocol-relative, http, or https.
        // For , SE only permits a limited subset of "href" values, so we can be more specific on these.
        regexText: {href: '(?:https?:)?//?[^" ]*'},
      },
      iframe: {
        // example: https://chat.stackexchange.com/transcript/11540?m=59301624#59301624
        general: ['width', 'height'],
        regexText: {src: 'https://(?:www\\.)?youtube\\.com/embed/[^" ]+'},
      },
      img: {
        ordered: ['src', 'width', 'height', 'alt', 'title'],
        isOptionallySelfClosing: true,
      },
      table: {specificValues: {class: ['s-table']}}, // example post with table: https://chat.stackoverflow.com/transcript/41570?m=51575339#51575339
      td: {specificValues: {style: ['text-align: right;', 'text-align: left;', 'text-align: center;']}}, // example post with td: https://chat.stackoverflow.com/transcript/41570?m=51575339#51575339
      th: {specificValues: {style: ['text-align: right;', 'text-align: left;', 'text-align: center;']}}, // example post with th: https://chat.stackoverflow.com/transcript/41570?m=51575339#51575339
    },
    optionallySelfClosingTagsWithNoAttributes: {
      br: [],
      hr: [],
    },
    withNoAttributes: {
      b: [],
      code: [],
      dd: [],
      del: [],
      dl: [],
      dt: [],
      em: [],
      i: [],
      kbd: [],
      li: [],
      p: [],
      s: [],
      strike: [],
      strong: [],
      sub: [],
      sup: [],
      tbody: [],
      thead: [],
      tr: [],
      ul: [],
    },
  };
  const whitelistedAttributesByTagType = Object.assign({},
    whitelistedTags.optionallySelfClosingTagsWithNoAttributes,
    whitelistedTags.withNoAttributes,
    whitelistedTags.withAttributes,
    whitelistedTags.specialCases);

  /*
    sortJqueryByDepth is modified from an answer to "jQuery traversing order - depth first" : https://stackoverflow.com/a/5756066
    Copyright 2011-04-22 16:27:08Z by alexl: https://stackoverflow.com/users/72562/alexl
    licensed under CC BY-SA 3.0
  */

  /**
   * sortJqueryByDepth - Sort the elements in a jQuery Object by depth, deepest first.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {jQuery}    input    The elements to sort
   *
   * @returns {jQuery}             New jQuery Object with the deepest elements first.
   */
  function sortJqueryByDepth(input) {
    const allElements = input.map(function () {
      return {length: $(this).parents().length, element: this};
    }).get();
    allElements.sort((a, b) => a.length - b.length);
    return $(allElements.map(({element}) => element));
  }

  /**
   * convertChildElementsWithNonWhitelistedAttributesToText - In place, convert to text all descendants of a container which have non-whitelisted attributes.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {jQuery}    container    The elements and their descendants to check.
   *
   */
  function convertChildElementsWithNonWhitelistedAttributesToText(container) {
    // Get all elements within a

    // Given that we might change some of the elements into text, this will allow us to get them again, if that occurs.
    // This no longer assumes that the current location in the DOM of the elements in the input does not need to be maintained (i.e. they will be moved).
    container = $(container);

    /**
     * convertElements - In place, one pass of converting to text the descendants of a container which have non-whitelisted attributes.
     *
     * @private
     * @memberof module:fire
     *
     * @param   {jQuery}     elementsIn    The elements and their descendants to check.
     *
     * @returns {boolean}                  Flag indicating if any changes were made.
     */
    function convertElements(elementsIn) {
      let didChange = false;
      elementsIn.each(function () {
        const attrList = [...this.attributes].map((attrNode) => attrNode.name.toLowerCase());
        const nodeType = this.nodeName.toLowerCase();
        const nodeTypeAttrList = whitelistedAttributesByTagType[nodeType];
        let shouldReplaceThis = false;
        if (!Array.isArray(nodeTypeAttrList) && typeof nodeTypeAttrList === 'object' && nodeTypeAttrList !== null) {
          // This is a special case tag.
          // Remove attributes which can have general values.
          const nonGeneralAttrs = attrList.filter((attr) => !Array.isArray(nodeTypeAttrList.general) || !nodeTypeAttrList.general.includes(attr));
          // Remove any which are specific values, where the value matches one which is permitted.
          const nonSpecificAttrs = nonGeneralAttrs.filter((attr) => !nodeTypeAttrList.specificValues || !Array.isArray(nodeTypeAttrList.specificValues[attr]) || !nodeTypeAttrList.specificValues[attr].includes(this.attributes[attr].nodeValue));
          const remainingAttrs = nonSpecificAttrs.filter((attr) => !nodeTypeAttrList.regexText || typeof nodeTypeAttrList.regexText[attr] !== 'string' || !(new RegExp(`^${nodeTypeAttrList.regexText[attr]}$`)).test(this.attributes[attr].nodeValue));
          const remainingUnorderedAttrs = remainingAttrs.filter((attr) => !Array.isArray(nodeTypeAttrList.ordered) || !nodeTypeAttrList.ordered.includes(attr));
          const remainingOrderedAttrs = remainingAttrs.filter((attr) => Array.isArray(nodeTypeAttrList.ordered) && nodeTypeAttrList.ordered.includes(attr));
          let foundIndex = -1;
          const areOrderedAttrsInOrder = remainingOrderedAttrs.every((attr) => {
            const newFoundIndex = nodeTypeAttrList.ordered.indexOf(attr);
            const newFoundIndexIsHigher = foundIndex < newFoundIndex;
            foundIndex = newFoundIndex;
            return newFoundIndexIsHigher;
          });
          shouldReplaceThis = remainingUnorderedAttrs.length > 0 || !areOrderedAttrsInOrder;
        } else if (!Array.isArray(nodeTypeAttrList) || !attrList.every((attr) => nodeTypeAttrList.includes(attr))) {
          // This isn't a valid tag
          shouldReplaceThis = true;
        }
        if (shouldReplaceThis) {
          const newOuterHTML = this.outerHTML.replace('<', '<').replace(/<(\/[a-z\d]+>)$/, '<$1');
          this.outerHTML = newOuterHTML;
          didChange = true;
        }
      });
      return didChange;
    }
    let allChildren;
    // Repeatedly do the conversion, until nothing is changed.
    // This can still end up with the HTML parsed incorrectly, but shouldn't have any elements which are
    // not whitelisted, or which are whitelisted element that have attributes which are not whitelisted.
    do {
      // Get all the elements again, and re-run the conversion.
      allChildren = sortJqueryByDepth(container.find('*'));
    } while (convertElements(allChildren));
  }

  const whiteListedSETagsRegex = (function () {
    // https://regex101.com/r/90UJ2K/1
    // https://regex101.com/r/9I7r9O/1
    /* eslint-disable no-useless-escape */
    const selfClosingBasicTagsRegexText = `(?:${Object.keys(whitelistedTags.optionallySelfClosingTagsWithNoAttributes).join('|')})\\s*/?`;
    const basicTagsRegexText = `\/?(?:${Object.keys(whitelistedTags.withNoAttributes).join('|')})\\s*`;
    const complexTagsClosingRegexText = `\/(?:${Object.keys(whitelistedTags.withAttributes).join('|')})\\s*`;
    const complexTagsRegexText = `(?:${Object.entries(whitelistedTags.withAttributes)
      .map(([tag, attrs]) => `(?:${tag}\\b(?: +(?:${attrs.join('|')})="[^"<>]*")*)`) // syntax highlighting fodder: "
      .join('|')})\\s*`;
    const specialCaseTagsClosingRegexText = `\/(?:${Object.keys(whitelistedTags.specialCases).join('|')})\\s*`;
    /* eslint-enable no-useless-escape */
    const specialCaseTagsRegexText = `(?:${Object.entries(whitelistedTags.specialCases).map(([tag, obj]) => {
      const unordered = [];
      if (Array.isArray(obj.general)) {
        unordered.push(`(?:${(obj.general || []).join('|')})="[^"<>]*"`); // syntax highlighting fodder: "
      }
      if (obj.specificValues) {
        unordered.push(Object.entries(obj.specificValues)
          .map(([specificValueAttr, values]) => `${specificValueAttr}="(?:${values.join('|')})"`)
          .join('|'));
      }
      if (obj.regexText) {
        unordered.push(Object.entries(obj.regexText)
          .map(([regexTextAttr, regexText]) => `${regexTextAttr}="${regexText}"`)
          .join('|'));
      }
      let unorderedRegexText = '';
      if (unordered.length > 0) {
        unorderedRegexText = `(?:(?: +(?:${unordered.join('|')}))*)`;
      }
      let allAttrs = unorderedRegexText;
      let orderedRegexText = '';
      if (obj.ordered) {
        orderedRegexText = `(?:(?: +${obj.ordered.join(`="[^"<>]*")?${unorderedRegexText}(?: +`)}="[^"<>]*")?)`;
        allAttrs = unorderedRegexText + orderedRegexText + unorderedRegexText;
      }
      const attrRegexText = `(?:${tag}\\b${allAttrs}\\s*${obj.isOptionallySelfClosing ? '/?' : ''})`;
      return attrRegexText;
    })
      .join('|')})`;
    const fullRegexText = `<(${[
      selfClosingBasicTagsRegexText,
      basicTagsRegexText,
      complexTagsClosingRegexText,
      complexTagsRegexText,
      specialCaseTagsClosingRegexText,
      specialCaseTagsRegexText,
    ].join('|')})>`;
    return new RegExp(fullRegexText, 'gi');
  })();
  /* The whitelisted RegExp is currently:
    2023-03-10:(https://regex101.com/r/YCHmek/1)
      <((?:br|hr)\s*\/?|\/?(?:b|code|dd|del|dl|dt|em|i|kbd|li|p|s|strike|strong|sub|sup|tbody|thead|tr|ul)\s*|\/(?:blockquote|div|ol|pre|span|h1|h2|h3|h4|h5|h6)\s*|(?:(?:blockquote\b(?: +(?:class)="[^"<>]*")*)|(?:div\b(?: +(?:class|data-lang|data-hide|data-console|data-babel)="[^"<>]*")*)|(?:ol\b(?: +(?:start)="[^"<>]*")*)|(?:pre\b(?: +(?:class)="[^"<>]*")*)|(?:span\b(?: +(?:class|dir)="[^"<>]*")*)|(?:h1\b(?: +(?:id)="[^"<>]*")*)|(?:h2\b(?: +(?:id)="[^"<>]*")*)|(?:h3\b(?: +(?:id)="[^"<>]*")*)|(?:h4\b(?: +(?:id)="[^"<>]*")*)|(?:h5\b(?: +(?:id)="[^"<>]*")*)|(?:h6\b(?: +(?:id)="[^"<>]*")*))\s*|\/(?:a|iframe|img|table|td|th)\s*|(?:(?:a\b(?:(?: +(?:(?:title|rel|alt|aria-label|aria-labelledby|data-onebox-id)="[^"<>]*"|class="(?:post-tag|post-tag required-tag|post-tag moderator-tag|onebox-link)"|href="(?:https?:)?\/\/?[^" ]*"))*)\s*)|(?:iframe\b(?:(?: +(?:(?:width|height)="[^"<>]*"|src="https:\/\/(?:www\.)?youtube\.com\/embed\/[^" ]+"))*)\s*)|(?:img\b(?:(?: +src="[^"<>]*")?(?: +width="[^"<>]*")?(?: +height="[^"<>]*")?(?: +alt="[^"<>]*")?(?: +title="[^"<>]*")?)\s*\/?)|(?:table\b(?:(?: +(?:class="(?:s-table)"))*)\s*)|(?:td\b(?:(?: +(?:style="(?:text-align: right;|text-align: left;|text-align: center;)"))*)\s*)|(?:th\b(?:(?: +(?:style="(?:text-align: right;|text-align: left;|text-align: center;)"))*)\s*)))>
  */

  /**
   * getHtmlAsDOMWrappedInDiv - Generate a
 containing the provided HTML text.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {string}          htmlText     The text to change into DOM nodes.
   *
   * @returns {DOM_node}
 containing the DOM node representation of the HTML text.
   */
  function getHtmlAsDOMWrappedInDiv(htmlText) {
    // If we just use jQuery to convert the HTML text to DOM, then the images are fetched, which might look suspicious if network traffic
    //   is being monitored (e.g. in a work environment) and the image is NSFW. This avoids that happening until such time as the elements
    //   are placed in the page DOM. Prior to that happening, we change the URL for images, if the user hasn't selected not to do so.
    // This also prevents various code execution attack vectors which don't function when the new nodes are not elements created in
    //   the page DOM. However, such code *IS* executed if the newly created nodes are just placed in the page DOM, even after this.
    //   Thus, it's necessary for subsequent processing to remove those types of attack vectors.
    // The htmlText may be malformed, so we wrap it in a div after conversion to DOM nodes.
    const parser = new DOMParser();
    const htmlAsDOM = parser.parseFromString(htmlText, 'text/html');
    // The body here will often have multiple child nodes. We want everything wrapped in a div, so:
    const newDiv = htmlAsDOM.createElement('div');
    newDiv.append(...htmlAsDOM.body.childNodes);
    return newDiv;
  }

  /**
   * generatePostBodyDivFromHtmlText - Generate a
 containing the HTML for a post body from HTML text.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {string}          htmlText     The text to change into HTML.
   * @param   {truthy/falsy}    isTrusted    Truthy if the text supplied is from SE (i.e. it's trusted / not pre-processed by SD).
   *
   * @returns {jQuery}
 containing the body HTML.
   */
  function generatePostBodyDivFromHtmlText(htmlText, isTrusted) {
    // Just having the whitelisted tags active is good, but results in the possibility that we've enabled
    //   a tag within .
    // div and pre can have class and other attributes for snippets.

    // SE normally provides HTML with  sections having <, >, and & (???) replaced by their HTML entities. The .body we get from MS has all HTML entities
    //   throughout the text (not just in  and
) replaced with their Unicode characters
    //   This is done to facilitate regex matching within the post, particularly within .
    //   With what SD has done to the text it's not, necessarily, possible to
    //   recover back to the actual content (e.g. what happens with Markdown like `<code>`: SE would send "<code>", but that gets converted
    //   to "" by SD/MS. In addition, it's possible there were originally other pieces of text which were HTML entities that are now
    //   Unicode characters which are incorrectly interpreted as valid HTML tags, etc.
    //   So, ultimately, the conversion from the body stored on MS to HTML text which we do here is, at best, an approximation. However, it's considerably
    //   better than not trying to perform that conversion at all.
    /*
      `<code>` foobar `</code>`
      produces
      <code> foobar </code>
      processed:
       foobar

      `<code></code> foobar <code></code>`
      produces
      <code></code> foobar <code></code>
      processed:
       foobar

          */
    // What we have in the MS provided body is that the  sections have had all of the >, < and ? & ? changed to actual characters, rather than the
    //   HTML entities, which is what SE provides. So, in order to display it (or compare it to what SE provides), we need to convert all of those back to the
    //   HTML entities. If we don't, then what's displayed could be incorrect.
    // At this point, it's reasonably consistently formatted HTML, due to being processed from Markdown by SE.
    // On deleted posts where the MS data isn't available, d.body could be undefined.

    let processedBody = htmlText;
    if (!isTrusted) {
      // If we are converting HTML text received from SE, then we don't need to handle the HTML
      //   entities having been converted to Unicode characters. We can just use the HTML text directly.
      whiteListedSETagsRegex.lastIndex = 0;
      const bodyOnlyWhitelist = $('
')
        .text(htmlText || '') // Escape everything. NOTE: Everything should be unescaped coming from SD/MS, but properly formatted if it's from SE.
        .html() // Get the escaped HTML, unescape whitelisted tags.
        .replace(whiteListedSETagsRegex, '<$1>');
      processedBody = toHTMLEntitiesBetweenTags(bodyOnlyWhitelist, 'code');
      processedBody = toHTMLEntitiesBetweenTags(processedBody, 'blockquote', whiteListedSETagsRegex);
    }
    // At this point, if we just pass the HTML to jQuery, then the images are fetched, which might look suspicious if network traffic is being monitored
    //   (e.g. in a work environment) and the image is NSFW. Still need to avoid that.
    // ProcessedBody may be malformed, so we don't wrap it in a div in HTML text.
    const containingDiv = getHtmlAsDOMWrappedInDiv(processedBody);
    if (!isTrusted) {
      convertChildElementsWithNonWhitelistedAttributesToText(containingDiv);
    }
    // Change all the image src prior to inserting into main document DOM or letting jQuery see it.
    // Not doing that here would result in the browser making fetches for each image's URL, which
    // could be bad for NSFW images in some situations (e.g. someone using this from work).
    // We could do this in the HTML text, but we want a DOM anyway, and it's easier to do it as DOM.
    containingDiv.querySelectorAll('img').forEach((image) => {
      image.dataset.src = image.src;
      image.src = 'https://via.placeholder.com/550x100//ffffff?text=Click+to+show+image.';
    });
    return $(containingDiv);
  }

  /**
   * pointRelativeURLsToSourceSESite - In place, change relative link URLs to point to the source SE site.
   *
   * @private
   * @memberof module:fire
   *
   * @param   {jQuery}    reportBody    jQuery Object containing the post body wrapped in a
.
   * @param   {object}    postData      The data for the post.
   */
  function pointRelativeURLsToSourceSESite(reportBody, postData) {
    // Convert relative URLs to point to the URL on the source site.
    // SE uses these for tags and some site-specific functionality (e.g. circuit simulation on Electronics)
    const [, siteHref] = (postData.link || '').match(/^((?:https?:)?\/\/(?:[a-z\d-]+\.)+[a-z\d-]+\/)/i) || ['', ''];
    // A couple of reports which had this problem:
    //   https://chat.stackexchange.com/transcript/11540?m=55601336#55601336  (links at bottom)
    //   https://chat.stackexchange.com/transcript/11540?m=54674140#54674140  (tags)
    reportBody.find('a').each(function () {
      const $this = $(this);
      let href = $this.attr('href');
      if (!/^(?:[a-z]+:)?\/\//.test(href)) {
        // It's not a fully qualified or protocol-relative link.
        if (href.startsWith('/')) {
          // The path is absolute
          if (siteHref.endsWith('/')) {
            href = href.replace('/', '');
          }
          this.href = siteHref + href;
        } else {
          // It's relative to the question (really shouldn't see any of these)
          if (!siteHref.endsWith('/')) {
            href = `/${href}`;
          }
          this.href = postData.link + href;
        }
      }
    });
  }
  /* eslint-enable */
})();


text:
import createDebug from 'debug';

import { onLoad } from './util';

const debug = createDebug('ms:user_site_settings');

onLoad(() => {
  $('#red-button').on('click', function () {
    const flagsEnabled = $(this).is(':checked');
    $.ajax({
      type: 'POST',
      url: '/flagging/preferences/enable',
      data: {
        enable: flagsEnabled
      }
    }).done(() => debug('saved :)')).error(xhr => debug('save failed', xhr));
  });
});


text:
$(() => {
  $('.js-preference-toggle').on('change', async evt => {
    const enabled = $(evt.target).is(':checked');
    const prefId = $(evt.target).data('pref-id');
    const resp = await fetch(`/email/preferences/${prefId}/toggle`, {
      method: 'POST',
      credentials: 'include',
      body: JSON.stringify({ enabled }),
      headers: { 'Content-Type': 'application/json' }
    });
    const data = await resp.json();
    console.log('pref-toggle', resp, data); // eslint-disable-line no-console
  });

  $('.js-preference-frequency').on('click', async evt => {
    $(evt.target).attr('disabled', true);
    const prefId = $('.js-preference-frequency').data('pref-id');
    const frequency = $(`.js-frequency[data-pref-id="${prefId}"]`).val();
    const resp = await fetch(`/email/preferences/${prefId}/frequency`, {
      method: 'POST',
      credentials: 'include',
      body: JSON.stringify({ frequency }),
      headers: { 'Content-Type': 'application/json' }
    });
    const data = await resp.json();
    console.log('pref-freq', resp, data); // eslint-disable-line no-console
    $(evt.target).attr('disabled', false);
  });

  $('.js-preference-delete').on('click', async evt => {
    evt.preventDefault();

    const $tgt = $(evt.target).hasClass('js-preference-delete') ? $(evt.target) : $(evt.target).parents('a.js-preference-delete');
    const prefId = $tgt.data('pref-id');
    const resp = await fetch(`/email/preferences/${prefId}/delete`, {
      method: 'POST',
      credentials: 'include'
    });
    const data = await resp.json();
    console.log('pref-delete', resp, data); // eslint-disable-line no-console
    $(evt.target).parents('.preference-container').fadeOut(200, function () {
      $(this).remove();
    });
  });
});


text:
import { onLoad, route, installSelectpickers } from './util';

onLoad(() => {
  $(document.body).on('ajax:success', 'a.feedback-button[data-remote]', e => {
    if (!$(e.target).hasClass('on-post')) {
      $('.post-cell-' + e.target.dataset.postId).remove();
      e.target.closest('tr').remove();
    }
  });
});

route(/\/review\/[\w-]+\/?\d*$/i, async () => {
  const queuePath = (/(\/review\/[\w-]+)\/?\d*$/i).exec(location.pathname)[1];
  const hasItemID = location.pathname.match(/\/review\/[\w-]+\/?\d+$/i);

  const adjustReviewPageTitle = () => {
    try {
      const pathname = window.location.pathname;
      const isHistory = pathname.endsWith('/history');
      const historyText = isHistory ? 'history for ' : '';
      const reviewTypeText = pathname.split('/')[2].split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
      const submitLink = $('.review-submit-link');
      const relativeUrl = (submitLink.length ? submitLink.first().attr('href') : pathname).replace(/\?.*/, '');
      const reviewID = (relativeUrl.match(/\d+/) || [''])[0];
      const reviewIDText = reviewID ? `: Review #${reviewID}` : '';
      let title = document.title;
      let extraText = '';
      if (!isHistory) {
        if (pathname.indexOf('/review/posts') === 0) {
          const titleEl = $('h4:not(.modal-title)');
          // If the titleEl doesn't exist, then all reviews are done.
          if (titleEl.length > 0) {
            const postTitle = $('h4')[0].firstChild.textContent.trim();
            const postID = $('h4 a').attr('href').replace(/^\D*(\d+)/, '$1');
            extraText = `: post ID ${postID}: ${postTitle}`;
          }
        }
        else if (pathname.indexOf('/review/untagged-domains') === 0) {
          extraText = `: ${$('h3').first().text()}`;
        }
      }
      title = `Review ${historyText}${reviewTypeText}${reviewIDText}${extraText} - metasmoke`;
      document.title = title;
      if (relativeUrl === pathname) {
        history.replaceState({}, title, relativeUrl);
      }
      else {
        history.pushState({}, title, relativeUrl);
      }
    }
    catch (err) {
      console.error(err); // eslint-disable-line no-console
    }
  };

  const loadNextPost = async () => {
    const filters = $('.review-filter').toArray();
    const params = {};
    filters.forEach(e => {
      const el = $(e);
      const val = el.val();
      if (val) {
        params[el.attr('name')] = val;
      }
    });
    const url = new URL(queuePath + '/next', `${location.protocol}//${location.host}`);
    const search = new URLSearchParams(params);
    url.search = search;

    const response = await fetch(url, {
      credentials: 'include'
    });
    const html = await response.text();
    $('.review-item-container').html(html);

    adjustReviewPageTitle();
    installSelectpickers();
    $(window).trigger('MS-review-loaded');
  };

  if (!hasItemID) {
    loadNextPost();
  }

  // The document will continue to exist even if we navigate to another page.
  // Thus, when we navigate back to this route, an additional listener will
  // be added. The document.body is replaced upon each SPA navigation.
  $(document.body).on('ajax:success', '.review-submit-link', () => {
    $('.review-item-container').text('Loading...');
    loadNextPost();
  });

  $(document.body).on('click', '.review-next-link', () => {
    $('.review-item-container').text('Loading...');
    loadNextPost();
  });

  $('#filter-button').click(() => {
    $('.review-item-container').text('Loading...');
    loadNextPost();
  });

  // Sort the site filter list
  const siteSelect = $('#site_id');
  const siteSelectOptions = siteSelect.find('option').toArray();
  siteSelect.append(siteSelectOptions.sort((a, b) => {
    if (a.value < 5 || b.value < 5) {
      return a.value - b.value;
    }
    return a.textContent.trim().localeCompare(b.textContent.trim());
  }));
  // Keep site filter selection through page reload
  siteSelect.val(localStorage['review-site-filter'] || 0);
  siteSelect.on('change', () => {
    localStorage['review-site-filter'] = siteSelect.val();
  });

  adjustReviewPageTitle();
});

route(/\/review\/untagged-domains(\/\d*)?/, () => {
  $(document.body).on('ajax:success', '.review-add-domain-tag', (e, data) => {
    const $noTags = $('.no-tags');
    if ($noTags.length > 0) {
      $noTags.remove();
      $('.domain-tag-list p').html('Tagged with: ' + data);
    }
    else {
      $('.domain-tag-list p').append(data);
    }
    $(e.target).find('select').selectpicker('val', null);
  });
});


text:
import { onLoad } from './util';

onLoad(() => {
  $('.expand-status-table').on('click', e => {
    $('.hidden-row').toggle();
    e.preventDefault();
  });
});


text:
import createDebug from 'debug';
import { onLoad } from './util';

/* global previewHandler */

const debug = createDebug('ms:flag_conditions');

onLoad(() => {
  window.previewHandler = ev => {
    const filter = $(ev.target).attr('data-filter') || '';

    debug(`preview-flag-conditions: click, filter=${filter}`);
    $.ajax({
      url: `/flagging/conditions/preview?filter=${filter}`,
      data: $('form').serialize()
    });
  };

  $('.preview-flag-conditions-button').on('click', previewHandler);
});


text:
import { onLoad } from './util';

setTimeout(() => {
  /* This is a patch to remove duplicated bootstrap-select dropdown selections.
   * It would be better to prevent these from being created.
   * This is added to onLoad() from a setTimeout, so it ends up being called
   * after other functions. Putting the operation within a setTimeout that's
   * called on each load results in the duplicate being visible briefly.
   */
  onLoad(() => {
    $('.dropdown.bootstrap-select > .dropdown.bootstrap-select').each(function () {
      const useSelect = $(this);
      useSelect.parent().replaceWith(useSelect);
    });
  });
}, 100);

(() => {
  /* This prevents multiple requests being made to the same URL within a short period of time.
   *   It should affect anywhere where more than one request of the same type is being made to
   *   the same URL within 100ms.
   */
  const minimumTimeBetweenBackToBackRequests = 100;
  const dupAjaxRequestIgnoredUrls = [
    'https://metasmoke.erwaysoftware.com/mini-profiler-resources/results',
    '/mini-profiler-resources/results'
  ];
  const priorAjaxSendInfo = {
    url: '',
    date: 0,
    type: ''
  };

  function dontPermitQuickBackToBackRequestsToSameURL(event, jqXHR, requestSettings) {
    const now = Date.now();
    if (!dupAjaxRequestIgnoredUrls.includes(requestSettings.url)) {
      if (priorAjaxSendInfo.date > (now - minimumTimeBetweenBackToBackRequests) && priorAjaxSendInfo.url === requestSettings.url && priorAjaxSendInfo.type === requestSettings.type) {
        // Abort this request which is to the same URL and within 100ms of the most recent prior request.
        console.error('Aborting rapid request to same url:\nTHIS MAY CONTAIN PRIVATE INFORMATION. Be careful when sharing.\nrequestSettings.url:', requestSettings.url, '::  requestSettings:', requestSettings, '::  jqXHR:', jqXHR, '::  event:', event); // eslint-disable-line no-console
        jqXHR.abort();
        return;
      }
    }
    priorAjaxSendInfo.date = now;
    priorAjaxSendInfo.url = requestSettings.url;
    priorAjaxSendInfo.type = requestSettings.type;
  }
  $(document).ajaxSend(dontPermitQuickBackToBackRequestsToSameURL);
})();

(() => {
  /* Auxiliary setting of Turbolinks ProgressBar. The goal is to have the ProgressBar show consistently,
   * as it's not currently shown for a substantial number of situations where there's a long wait.
   * This has the ProgressBar displayed for both Turbolinks navigation and for AJAX requests. This
   * should result in the user being more aware that something is actually happening, rather than
   * wondering if their click was effective.
   * In addition, this disables clicking on the same control until the AJAX request is complete.
   * That should result in fewer situations where a user repeatedly clicks on an action with takes
   * MS substantial time to process, causing MS to repeatedly allocate resources to performing the
   * same task multiple times.
   */
  /* globals Turbolinks */
  let previousClickTarget = null;
  let previousClickTimestamp = 0;
  const withinMillisecondsForClickToMatchAjax = 50;
  const notTrackedAjaxUrls = [
    'https://metasmoke.erwaysoftware.com/mini-profiler-resources/results',
    '/mini-profiler-resources/results'
  ];
  let pendingTurbolinksVisit = null;
  let pendingTurbolinksVisitTimeout = null;
  const millisecondsWaitForTurbolinksVisit = 500;

  /* The safeStartTurbolinksProgress() and safeStopTurbolinksProgress() functions are from
   *   [answer to: "Turbolinks 5 : Show progress Bar"](https://stackoverflow.com/a/38490830/3773011)
   * by [dimroc](https://stackoverflow.com/users/639773/dimroc)
   * which is copyright 2016 and under a CC BY-SA 3.0 license.
   * The functions have been modified.
   */
  function safeStartTurbolinksProgress() {
    if (Turbolinks.supported) {
      Turbolinks.controller.adapter.progressBar.setValue(0);
      Turbolinks.controller.adapter.progressBar.show();
    }
  }

  function safeStopTurbolinksProgress() {
    if (Turbolinks.supported) {
      Turbolinks.controller.adapter.progressBar.hide();
      Turbolinks.controller.adapter.progressBar.setValue(100);
    }
  }

  // This is just a counter which allows JavaScript to explicitly identify AJAX requests.
  let turboLinksXhrId = 0;
  function getNewXhrID() {
    turboLinksXhrId++;
    return turboLinksXhrId;
  }

  function addIDToJqueryXhrOnEvent(event, jqXHR, requestSettings) {
    const url = requestSettings.url;
    if (!notTrackedAjaxUrls.includes(url)) {
      addXhrIDToObjectAndWaitForAjax(jqXHR, url);
    }
  }

  function didNotHaveTurbolinksVisitWithinTimeout() {
    // We didn't see a Turbolinks:visit event with the pending URL. We assume this means we are not navigating.
    pendingTurbolinksVisit = null;
    pendingTurbolinksVisitTimeout = null;
    safeStopTurbolinksProgress();
  }

  function addIDToTurbolinksXhrOnEvent(event) {
    // We first get here on mousedown.
    // Turbolinks fails if we cancel the click event which happens after the mousedown.
    addXhrIDToObjectAndWaitForAjax(event.originalEvent.data.xhr, event.originalEvent.data.url, false);
    if (event.type === 'turbolinks:request-start' && event.originalEvent.data.xhr) {
      pendingTurbolinksVisit = event.originalEvent.data.xhr.msData;
      pendingTurbolinksVisitTimeout = setTimeout(didNotHaveTurbolinksVisitWithinTimeout, millisecondsWaitForTurbolinksVisit);
    }
    if (event.type === 'turbolinks:visit' && pendingTurbolinksVisit && event.originalEvent.data.url === pendingTurbolinksVisit.url) {
      // This is the expected :visit event.
      clearTimeout(pendingTurbolinksVisitTimeout);
      // We disable/wait-for-AJAX the element which started this visit
      disableElementAndIndicateWaitForAjax(pendingTurbolinksVisit.clickTarget);
    }
  }

  function disableElementAndIndicateWaitForAjax(el) {
    $(el)
      .addClass('wait-for-AJAX')
      .prop('disabled', true)
      .attr('disabled', true);
  }

  function addXhrIDToObjectAndWaitForAjax(obj, url, addWaitForAjax = true) {
    if ($(previousClickTarget).closest('a.dropdown-toggle').length > 0) {
      return;
    }
    if (obj && typeof obj.msData !== 'object') {
      obj.msData = {
        id: getNewXhrID(),
        url
      };
      const timeAgo = Date.now() - previousClickTimestamp;
      if (timeAgo <= withinMillisecondsForClickToMatchAjax) {
        obj.msData.clickTarget = previousClickTarget;
        // Only one associated
        if (addWaitForAjax) {
          disableElementAndIndicateWaitForAjax(previousClickTarget);
        }
        if (previousClickTarget) {
          safeStartTurbolinksProgress();
        }
        previousClickTimestamp = 0;
        previousClickTarget = null;
      }
    }
  }

  function enableSavedTargetInJqueryXhrEvent(event, jqXHR) {
    if (jqXHR.msData && jqXHR.msData.clickTarget) {
      safeStopTurbolinksProgress();
      $(jqXHR.msData.clickTarget)
        .removeClass('wait-for-AJAX')
        .prop('disabled', false)
        .attr('disabled', false);
    }
  }

  function rememberMouseDown(event) {
    if (event.button !== 0) {
      return;
    }
    previousClickTarget = event.target;
    previousClickTimestamp = Date.now();
  }

  function cancelEventIfWaitForAjaxOrDisabled(event) {
    const target = event.target;
    if (target.classList.contains('wait-for-AJAX') || target.classList.contains('disabled') || (target.hasAttribute('disabled') && target.getAttribute('disabled') !== 'false')) {
      event.stopImmediatePropagation();
      event.preventDefault();
    }
  }

  // Remember clicks
  window.addEventListener('mousedown', rememberMouseDown, true); // Turbolinks starts fetching upon mousedown.
  window.addEventListener('mouseup', rememberMouseDown, true);
  window.addEventListener('click', rememberMouseDown, true);
  // Cancel events when waiting for AJAX for the target or target is disabled
  window.addEventListener('mousedown', cancelEventIfWaitForAjaxOrDisabled, true);
  window.addEventListener('mouseup', cancelEventIfWaitForAjaxOrDisabled, true);
  window.addEventListener('click', cancelEventIfWaitForAjaxOrDisabled, true);

  $(document)
    .ajaxSend(addIDToJqueryXhrOnEvent)
    .ajaxComplete(enableSavedTargetInJqueryXhrEvent);
  $(window).on('turbolinks:request-start turbolinks:before-visit turbolinks:visit', addIDToTurbolinksXhrOnEvent);
})();


text:
import { route } from './util';

route(/\/dev\/request-log.*/, () => {
  function percentageToHsl(percentage, hue0, hue1) {
    const hue = (percentage * (hue1 - hue0)) + hue0;
    return 'hsl(' + hue + ', 100%, 25%)';
  }

  ['.request-db-time', '.request-view-time'].forEach(cls => {
    let times = $(cls).map((i, s) => parseFloat($(s).text().trim().replace('ms', '')));
    times = times.toArray().filter(x => !isNaN(x));
    const max = Math.max(...times);
    const min = Math.min(...times);
    const distance = t => {
      return t / (max - min);
    };

    $(cls).each((i, s) => {
      const time = parseFloat($(s).text().trim().replace('ms', ''));
      if (isNaN(time)) {
        return;
      }

      $(s).css('color', percentageToHsl(distance(time), 120, 0));
    });
  });
});


text:
import createDebug from 'debug';

import { route } from './util';

const debug = createDebug('ms:data');

window.store = {};
/* globals store, ace */

const loadPromise = ($el, gbl) => new Promise(resolve => window[gbl] ? resolve() : $el.on('load', resolve));

const addDataListRow = () => {
  $('.data-list').prepend($('#data-list-row').clone().removeClass('template'));
};

const displaySchema = async table => {
  $.ajax({
    type: 'GET',
    url: `/data/schema?table=${table}`
  }).done(data => {
    $('.schema-display').show();
    $('.schema-table-name').text(table);
    $('.table-schema').html(data.join('<br/>'));
  }).fail(xhr => {
    debug('Failed to get schema:', xhr);
  });
};

const fetchDataMultiple = async (types, limits) => {
  const required = [];
  for (let i = 0; i < types.length; i++) {
    if (!store.hasOwnProperty(types[i]) || store[types[i]].length !== limits[types[i]]) {
      required.push(types[i]);
    }
  }

  $.ajax({
    type: 'GET',
    url: '/data/retrieve',
    data: {
      types: required,
      limits
    }
  }).done(data => {
    const keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i];
      store[key] = data[key];
    }
  }).fail(xhr => {
    debug('Couldn\'t load data:', xhr);
  });
};

const fetchData = (type, limit) => {
  const limits = {};
  limits[type] = limit;
  fetchDataMultiple([type], limits);
};

const humanize = function (s) {
  s = s.replace(/[-_]/g, ' ');
  return s.charAt(0).toUpperCase() + s.slice(1);
};

const preloadDataTypes = function () {
  const types = ['announcements', 'api_keys', 'commit_statuses', 'deletion_logs', 'feedbacks',
    'flag_conditions', 'flag_logs', 'flag_settings', 'moderator_sites', 'posts', 'reasons', 'roles',
    'sites', 'smoke_detectors', 'stack_exchange_users', 'statistics', 'user_site_settings', 'users'];
  const select = $('#data-list-row').find('.data-type-select');
  for (let i = 0; i < types.length; i++) {
    const dataType = types[i];
    const $option = $('<option>').val(dataType).text(humanize(dataType));
    select.append($option);
  }
};

const appendResults = (results, $resultBody) => {
  for (let i = 1; i < results.length; i++) {
    const $row = $('<tr>');
    for (let m = 0; m < results[i].length; m++) {
      $row.append($('<td>').text(results[i][m]));
    }
    $resultBody.append($row);
  }
};

const renderResults = (err, results) => {
  if (err) {
    debug('failed:', err);
    $('.js-script-error').attr('data-content', err.stack).fadeIn();
    return;
  }
  try {
    $('.results-table').show();

    const $headerRow = $('.results-header');
    const $resultBody = $('.results-body');

    $headerRow.find('th').remove();
    $resultBody.find('tr').remove();

    if (!(Array.isArray(results))) {
      throw new TypeError('results is not an array; can\'t render it');
    }

    const typeCheck = results.map(x => Array.isArray(x));
    if (typeCheck.indexOf(false) >= 0) {
      throw new Error('Not all elements of results are arrays; can\'t render results');
    }

    if (results.length >= 1) {
      const headers = results[0];
      for (let i = 0; i < headers.length; i++) {
        $headerRow.append($('<th>').text(headers[i]));
      }

      if (results.length >= 2) {
        appendResults(results, $resultBody);
      }
    }

    $('.js-script-error').popover('hide').fadeOut();
  }
  catch (err) {
    $('.js-script-error').attr('data-content', err.stack).fadeIn();
  }
};

const validateDataset = () => {
  const types = [];
  const limits = {};
  $('.data-list-item').each((i, item) => {
    if ($(item).hasClass('template')) {
      return;
    }

    const $this = $(item);
    const type = $this.find('.data-type-select').first().val();
    const limit = $this.find('.data-type-limit').first().val();
    if (type && limit && type.length > 0 && limit.length > 0) {
      types.push(type);
      limits[type] = limit;
    }
  });

  const storedTypes = Object.keys(store);
  const surplusTypes = storedTypes.filter(x => types.indexOf(x) < 0);
  for (let m = 0; m < surplusTypes.length; m++) {
    delete store[surplusTypes[m]];
  }

  fetchDataMultiple(types, limits);
};

const themes = {
  dark: 'ace/theme/monokai',
  light: 'ace/theme/xcode'
};
let theme = localStorage.editorTheme || themes.dark;

let editor;
route('/data', async () => {
  preloadDataTypes();
  $('.schema-display').hide();
  $('.script-help').hide();

  await loadPromise($('.js-ace'), 'ace');

  const url = new URL(location.href);
  url.pathname = '/data_sandbox.js';

  editor = ace.edit('editor');
  if (localStorage.dataExplorerScriptContent) {
    editor.getSession().getDocument().setValue(localStorage.dataExplorerScriptContent);
  }
  // Why? Because.
  editor.$blockScrolling = Infinity;
  editor.getSession().setMode('ace/mode/javascript');
  editor.setOptions({
    minLines: 15,
    maxLines: 30,
    useSoftTabs: true,
    tabSize: 2,
    printMarginColumn: 120
  });
  editor.resize();

  $('.js-theme-toggle').click(e => {
    e.preventDefault();
    if (theme === themes.dark) {
      theme = themes.light;
      $('.js-theme-toggle').text('🌙');
    }
    else {
      theme = themes.dark;
      $('.js-theme-toggle').text('☀');
    }
    editor.setTheme(theme);
    localStorage.editorTheme = theme;
  });
  $('.js-theme-toggle').click().click();

  $('.add-data').on('click', ev => {
    ev.preventDefault();
    addDataListRow();
  });

  $(document.body).on('change', '.data-type-limit, .data-type-select', ({ target }) => {
    const $this = $(target);
    const isLimit = $this.hasClass('data-type-limit');
    const $limit = isLimit ? $this : $this.siblings(isLimit ? '.data-type-select' : '.data-type-limit').first();
    const $select = isLimit ? $this.siblings(isLimit ? '.data-type-select' : '.data-type-limit').first() : $this;

    if ($limit.val() && $select.val() && $limit.val().length > 0 && $select.val().length > 0) {
      const type = $select.val();
      const limit = $limit.val();
      fetchData(type, limit);
    }
  });

  $(document.body).on('change', '.data-type-select', ({ target }) => {
    const $this = $(target);
    if ($this.val().length > 0) {
      displaySchema($this.val());
    }
  });

  $('.toggle-script-help').on('click', ev => {
    ev.preventDefault();
    $('.script-help').slideToggle(500);
  });

  $('.run-script').on('click', ({ target }) => {
    const $this = $(target);
    $this.attr('disabled', 'disabled');

    validateDataset();

    let results = null;
    let error = null;
    try {
      // eslint-disable-next-line no-eval
      results = eval(editor.getValue())(store);
    }
    catch (err) {
      error = err;
    }

    renderResults(error, results);

    const csv = btoa(results.map(x => x.join(',')).join('\n'));
    const uri = `data:text/csv;base64,${csv}`;
    $('.csv-download').attr('href', uri);

    $this.removeAttr('disabled');
  });
}, () => {
  localStorage.dataExplorerScriptContent = editor.getValue();
});


text:
$(document).on('ajax:success', '#new_domain_link', (ev, data) => {
  $('.domain-links-list').append(data);
  $(ev.target).parents('.modal').modal('hide');
});


text:
import createDebug from 'debug';

import { onLoad } from './util';

const debug = createDebug('ms:stack_exchange_users');

onLoad(() => {
  $('img.stack_exchange_user_flair').error(function () {
    $(this).replaceWith(`<h3><img src="${$(this).data('site-logo')}">${$(this).data('username')} (${$(this).data('reputation')})</h3><p class="text-danger">(user has been deleted)</p>`);
  });
  $('.not-spammer').click(function (e) {
    e.preventDefault();
    $.ajax({
      type: 'POST',
      url: '/spammers/dead/' + $(this).data('uid')
    }).done(data => {
      if (data === 'ok') {
        const $tr = $(this).parent().parent();
        $tr.fadeOut(200, () => $tr.remove());
      }
      else {
        debug('something went wrong: update returned', data);
      }
    }).fail(jqXHR => debug('something went wrong: update failed:', jqXHR.status, jqXHR.responseText, '\n', jqXHR));
  });
});


text:
import createDebug from 'debug';

const debug = createDebug('ms:util');
// Common utilities

// call `enter`(pathname) when visiting `path` (string or regex)
// call `exit`(pathname) when leaving `path`
const routes = [];
$(document).on('turbolinks:load', () => {
  const { pathname } = location;
  // Call the exit function for all routes which were current.
  for (const route of routes) {
    if (route.current) {
      // We've loaded a new page.  We need to call the the exit function for any routes that
      // matched the prior page (i.e.  are marked as current), even if we're transitioning
      // to a page which will also match this route.  If we don't always do this, then code
      // which is setting up a new page can end up either not being called too many times
      // without tearing down what's been set up.
      // Note that the exit function is being called when the new  DOM already exists,
      // so the exit function doesn't need to undo changes which were made to the prior body.
      if (typeof route.exit === 'function') {
        route.exit.call(null, pathname);
      }
    }
  }
  // Call the enter function for all matching routes.
  for (const route of routes) {
    route.current = false;
    if (route.pathisRe ? route.path.test(pathname) : route.path === pathname) {
      // Note: for forward/back translations the state of the DOM may be notably different
      // than for loading a new page.
      route.enter.call(null, pathname);
      route.current = true;
    }
  }
});

$(window).on('beforeunload', () => {
  debug('onbeforeunload');
  // Call all existing exit functions for current routes.
  //
  // Note that the exit functions are being called when the DOM for the page which
  // matched the route still exists at this time, which is different than for Turbolinks
  // transitions where the exit function is called after the new  DOM exists.
  const currentRoutes = routes.filter(route => route.current);
  currentRoutes.forEach(route => {
    route.current = false;
    if (typeof route.exit === 'function') {
      route.exit.call(null);
    }
  });
});

// Run a function upon loading a "new page" that matches `path` and/or having exited a
// page where the `path` matched upon loading.  These are called upon either initial loading
// of an MS page or for Turbolinks transitions to "new" pages.  Turbolinks replaces the
//  element. Both the enter and exit functions are called after the new DOM exists.
// In addition, the exit function is called on the window beforeunload event just prior to
// an actual transition to a completely new page. At that point a new DOM does not exist.
//
// path can be either a String, which must be an exact match to the
//   window.location.pathname, or a RegExp, which must return true for
//   path.test(window.location.pathname).
// The enter function is called with the window.location.pathname of the *new* page when
//   transitioning to a new page, either a fresh page load or a Turbolinks load, when the
//   window.location.pathname matches the supplied path.
// The exit function is called with the window.location.pathname of the *new* page when
//   a new page DOM *has already been loaded* using a Turbolinks transition when the
//   window.location.pathname of the *prior* page matched the supplied path.
// All applicable exit functions are called prior to any enter functions for the new page.
export function route(path, enter, exit = () => {}) {
  if (!path || !enter) {
    throw new Error('Expecting at least two arguments to utils.route(), got: ' + JSON.stringify(arguments));
  }
  routes.push({
    path,
    pathisRe: path instanceof RegExp,
    enter,
    exit,
    current: false
  });
}

export function onLoad(cb) {
  $(document).on('turbolinks:load', cb);
}

export function installSelectpickers() {
  $('.selectpicker').selectpicker({
    dropupAuto: true,
    liveSearch: true,
    liveSearchNormalize: true,
    showSubtext: true,
    noneResultsText: 'No results matched {0}. Hit Enter to create it.'
  });

  // Selectpicker "extension": custom input. If there are no options matching the live search,
  // create a new one, add it to the list, and select it.
  $('.bs-searchbox input[type="search"]').on('keydown', ev => {
    const tgt = $(ev.target);
    const list = tgt.parents('.dropdown-menu');

    // User hit Enter and there were no results found for the given input
    if (ev.keyCode === 13 && list.find('li.no-results').length > 0) {
      ev.preventDefault();

      const select = tgt.parents('div.bootstrap-select').find('select');
      const input = tgt.val();
      const option = $(`<option value="${input}">${input}</option>`);
      option.appendTo(select);
      select.selectpicker('refresh')
      .selectpicker('val', input)
      .selectpicker('toggle');
    }
  });

  // Another extension: AJAX data sources. Add data-remote-source to a .selectpicker to activate this.
  // Will send a request to that route with ?q=user-text-here
  const sendSearchRequest = async (source, target) => {
    target = $(target);
    const input = target.val();
    const response = await fetch(`${source}?q=${encodeURIComponent(input)}`);
    const data = await response.json();

    const options = data.map(i => `<option value="${i.value}">${i.text}</option>`);
    const select = target.parents('div.bootstrap-select').find('select');
    select.empty();
    $('<option value></option>').appendTo(select);
    options.forEach(o => $(o).appendTo(select));
    select.selectpicker('refresh');
  };

  let timeout;

  $('.selectpicker[data-remote-source]').each((i, e) => {
    const source = $(e).data('remote-source');

    $(e).selectpicker({
      dropupAuto: true,
      liveSearch: true,
      liveSearchNormalize: true,
      showSubtext: true,
      noneResultsText: 'No results matched your search or you need to enter more characters to search with.'
    });

    $(e).parent().find('.bs-searchbox input[type="search"]').on('keydown input', ev => {
      if ($(ev.target).val().length < 2) {
        return;
      }

      if (timeout) {
        clearTimeout(timeout);
      }

      timeout = setTimeout(() => {
        sendSearchRequest(source, ev.target);
      }, 200);
    });
  });

  // For domain tag selection, copy the class and style from the selected option to the .filter-option-inner-inner.
  function useOptionClassAndStyleForSelectedDropdownOption(event, clickedIndex, isSelected) {
    const $target = $(event.target);
    const dropdown = $target.closest('.dropdown');
    const innerInner = dropdown.find('.filter-option-inner-inner').first();
    innerInner[0].className = 'filter-option-inner-inner';
    innerInner.removeAttr('style');
    if (isSelected) {
      const option = event.target.options[clickedIndex];
      innerInner
        .addClass(option.className)
        .attr('style', $(option).attr('style'));
    }
  }
  $('.domain-tag-list #tag_name').off('changed.bs.select');
  $('.domain-tag-list #tag_name').on('changed.bs.select', useOptionClassAndStyleForSelectedDropdownOption);
  // For all AS-XXXX tags indicate that they are "special". The class really should be based on the database, not a text match.
  $('.domain-tag-list option[value^="AS-"]').addClass('special-tag');
  $('.domain-tag-list div.bootstrap-select select').selectpicker('refresh');
  $('.domain-tag-list .dropdown-toggle:not(.bs-placeholder) .filter-option-inner-inner').each(function () {
    const valueText = this.firstChild.textContent;
    const $this = $(this);
    const selectedOption = $this.closest('.bootstrap-select').find(`select option[value="${valueText}"]`);
    if (selectedOption.length > 0) {
      $this
        .addClass(selectedOption[0].className)
        .attr('style', selectedOption.attr('style'));
    }
  });
}

// Cred. broofa et. al. https://stackoverflow.com/a/2117523
export function uuid4() {
  return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
    (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
  );
}

export function hashCode(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const character = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + character;
    hash &= hash;
  }
  return hash;
}

// Local Settings panel
export function addLocalSettingsHeaderToAccount() {
  if ($('.local-settings-panels-container').length === 0) {
    $('div.row').append(`

Local Browser Settings

These settings apply only to the current browser.

`);
  }
}

export function addLocalSettingsPanel(content) {
  addLocalSettingsHeaderToAccount();
  const panelsContainer = $('.local-settings-panels-container');
  const panel = $(`

`);
  panel.find('.panel-body').append(content);
  panelsContainer.append(panel);
}


text:
import createDebug from 'debug';

import { onLoad } from './util';

const debug = createDebug('ms:reasons');

// This really is the wrong file for all of this
$(() => {
  $(document).on('click', '.show-post-body', function () {
    if ($(this).data('postloaded')) {
      togglePostBodyVisible(this);
    }
    else {
      // If we need to lazy-load the post body from the server
      // This is criminally ugly
      $(this).parent().children('div.post-body:first').load(`/post/${$(this).data('postid')}/body`, () => {
        debug('post data loaded', $(this)[0]);
        togglePostBodyVisible($(this));
        $(this).data('postloaded', true);
      });
    }
  });

  $(document).on('keyup', '#search', function () {
    const val = $.trim($(this).val()).replace(/ +/g, ' ').toLowerCase();
    debug('searching for', `"${val}"`);
    $('tr').not('tr:first').show().filter(function () {
      const text = $(this).text().replace(/\s+/g, ' ').toLowerCase();
      return text.includes(val);
    }).hide();
  });
  $(document).on('click', 'li.search-icon a', e => {
    $('#search').focus();
    e.preventDefault();
  });
});

onLoad(() => {
  setTimeout(() => $('#search').addClass('ready'), 100);
});

function togglePostBodyVisible(row) {
  $(`.post-body[data-postid="${$(row).data('postid')}"]`).toggle();
  if ($(row).text() === '►') {
    $(row).text('▼');
  }
  else if ($(row).text() === '▼') {
    $(row).text('►');
  }
}


text:
import createDebug from 'debug';

import { onLoad } from './util';

const debug = createDebug('ms:api');

onLoad(() => {
  $('#create_filter').on('click', () => {
    const fields = $('label:has(input[type=checkbox]:checked)').toArray().map(el => $(el).text().trim());
    $.ajax({
      type: 'POST',
      url: '/api/filters',
      data: {
        fields
      }
    })
    .done(data => {
      prompt('This is your filter. Copy it and use it as the filter query string parameter on API requests.', data.filter);
    })
    .fail(xhr => {
      debug(xhr.status);
      debug(xhr);
    });
  });
});