archivesspace/archivesspace

View on GitHub
frontend/app/controllers/application_controller.rb

Summary

Maintainability
F
5 days
Test Coverage
require 'asconstants'
require 'memoryleak'
require 'search'
require 'zlib'

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  helper :all

  rescue_from ArchivesSpace::SessionGone, :with => :destroy_user_session
  rescue_from ArchivesSpace::SessionExpired, :with => :destroy_user_session
  rescue_from RecordNotFound, :with => :render_404
  rescue_from AccessDeniedException, :with => :render_403

  # Allow overriding of templates via the local folder(s)
  if not ASUtils.find_local_directories.blank?
    ASUtils.find_local_directories.map {|local_dir| File.join(local_dir, 'frontend', 'views')}.reject { |dir| !Dir.exist?(dir) }.each do |template_override_directory|
      prepend_view_path(template_override_directory)
    end
  end

  # Note: This should be first!
  before_action :store_user_session, unless: -> { params[:login] && !session[:user] }

  before_action :refresh_permissions, unless: -> { params[:login] && !session[:user] }

  before_action :refresh_preferences, unless: -> { params[:login] && !session[:user] }

  before_action :load_repository_list, unless: -> { params[:login] && !session[:user] }

  before_action :unauthorised_access

  before_action :init_ancestor_titles

  around_action :set_locale

  def self.permission_mappings
    Array(@permission_mappings)
  end

  def self.can_access?(context, method)
    permission_mappings.each do |permission, actions|
      if actions.include?(method) && !session_can?(context, permission)
        return false
      end
    end

    return true
  end


  def self.set_access_control(permission_mappings)
    @permission_mappings = permission_mappings

    skip_before_action :unauthorised_access, :only => Array(permission_mappings.values).flatten.uniq

    permission_mappings.each do |permission, actions|
      next if permission === :public

      before_action(:only => Array(actions)) {|c| user_must_have(permission)}
    end
  end

  protected

  def inline?
    params[:inline] === "true"
  end


  # Perform the common create/update logic for our various CRUD controllers:
  #
  #  * Take user parameters and massage them a bit
  #  * Grab the existing instance of our JSONModel
  #  * Update it from the parameters
  #  * If all looks good, send the user off to their next adventure
  #  * Otherwise, throw the form back with warnings/errors
  #
  def handle_crud(opts)
    begin
      # Start with the JSONModel object provided, or an empty one if none was
      # given.  Update it from the user's parameters
      model = opts[:model] || JSONModel(opts[:instance])
      obj = opts[:obj] || model.new

      obj.instance_data[:find_opts] = opts[:find_opts] if opts.has_key? :find_opts

      # We need to retain any restricted properties from the existing object. i.e.
      # properties that exist for the record but the user was not allowed to edit
      unless params[:action] == 'copy'
        if params[opts[:instance]].key?(:restricted_properties)
          params[opts[:instance]][:restricted_properties].each do |restricted|
            next unless obj.has_key? restricted

            params[opts[:instance]][restricted] = obj[restricted].dup
          end
        end
      end

      # Param validations that don't have to do with the JSON validator
      opts[:params_check].call(obj, params) if opts[:params_check]

      instance = cleanup_params_for_schema(params[opts[:instance]], model.schema)

      if opts[:before_hooks]
        opts[:before_hooks].each { |hook| hook.call(instance) }
      end

      if opts[:replace] || opts[:replace].nil?
        obj.replace(instance)
      elsif opts[:copy]
        obj.name = "Copy of " + obj.name
        obj.uri = ''
      else
        obj.update(instance)
      end

      if opts[:required_fields]
        opts[:required_fields].add_errors(obj)
      end

      # Make the updated object available to templates
      instance_variable_set("@#{opts[:instance]}".intern, obj)

      if not params.has_key?(:ignorewarnings) and not obj._warnings.empty?
        # Throw the form back to the user to confirm warnings.
        instance_variable_set("@exceptions".intern, obj._exceptions)
        return opts[:on_invalid].call
      end

      if obj._exceptions[:errors]
        instance_variable_set("@exceptions".intern, clean_exceptions(obj._exceptions))
        return opts[:on_invalid].call
      end

      if opts.has_key?(:save_opts)
        id = obj.save(opts[:save_opts])
      elsif opts[:instance] == :user and !params['user']['password'].blank?
        id = obj.save(:password => params['user']['password'])
      else
        id = obj.save
      end

      opts[:on_valid].call(id)
    rescue ConflictException
      instance_variable_set(:"@record_is_stale".intern, true)
      opts[:on_invalid].call
    rescue JSONModel::ValidationException => e
      # Throw the form back to the user to display error messages.
      instance_variable_set("@exceptions".intern, obj._exceptions)
      opts[:on_invalid].call
    end
  end


  def handle_merge(victims, target_uri, merge_type, extra_params = {})
    request = JSONModel(:merge_request).new
    request.target = {'ref' => target_uri}
    request.victims = Array.wrap(victims).map { |victim| { 'ref' => victim } }
    if params[:id]
      id = params[:id]
    else
      id = target_uri.split('/')[-1]
    end
    begin
      request.save(:record_type => merge_type)

      flash[:success] = I18n.t("#{merge_type}._frontend.messages.merged")

      if merge_type == 'top_container'
        redirect_to(:controller => :top_containers, :action => :index)
      else
        resolver = Resolver.new(target_uri)
        redirect_to(resolver.view_uri)
      end
    rescue ValidationException => e
      flash[:error] = e.errors.to_s
      redirect_to({:action => :show, :id => id}.merge(extra_params))
    rescue ConflictException => e
      flash[:error] = I18n.t("errors.merge_conflict", :message => e.conflicts)
      redirect_to({:action => :show, :id => id}.merge(extra_params))
    rescue RecordNotFound => e
      flash[:error] = I18n.t("errors.error_404")
      redirect_to({:action => :show, :id => id}.merge(extra_params))
    end
  end


  def handle_accept_children(target_jsonmodel)
    unless params[:children]
      # Nothing to do
      return render :json => {
                      :position => params[:index].to_i
                    }
    end

    response = JSONModel::HTTP.post_form(target_jsonmodel.uri_for(params[:id]) + "/accept_children",
                                         "children[]" => params[:children],
                                         "position" => params[:index].to_i)




    if response.code == '200'
      render :json => {
        :position => params[:index].to_i
      }
    else
      raise "Error setting parent of archival objects: #{response.body}"
    end
  end

  # could be generalized further by accepting related_type [ex: event] and related_field [ex: linked_record_uris]
  def fetch_linked_events_count(type, id)
    uri = JSONModel(type).uri_for(id)
    Search.for_type(session[:repo_id], "event", params_for_backend_search.merge(
      {"facet[]" => SearchResultData.EVENT_FACETS, "q" => "linked_record_uris:\"#{uri}\"", "fields[]" => "id"}
    ))['total_hits']
  end

  def fetch_resolved(type, id, excludes: [])
    # We add this so that we can get a top container location to display with the instance view
    new_find_opts = find_opts
    new_find_opts["resolve[]"].push("top_container::container_locations")
    new_find_opts["resolve[]"] = new_find_opts["resolve[]"] - excludes

    record = JSONModel(type).find(id, new_find_opts)

    if record['classifications']
      record['classifications'].each do |classification|
        next unless classification['_resolved']
        resolved = classification["_resolved"]
        resolved['title'] = ClassificationHelper.format_classification(resolved['path_from_root'])
      end
    end

    record
  end

  def find_opts
    {
      "resolve[]" => ["subjects", "related_resources", "linked_agents",
                      "revision_statements",
                      "container_locations", "digital_object", "classifications",
                      "related_agents", "resource", "parent", "creator",
                      "linked_instances", "linked_records", "related_accessions",
                      "linked_events", "linked_events::linked_records",
                      "linked_events::linked_agents",
                      "top_container", "container_profile", "location_profile",
                      "owner_repo", "places", "component_links", "accession_links"] + Plugins.fields_to_resolve
    }
  end

  def user_is_global_admin?
    if AppConfig[:allow_other_admins_access_to_system_info]
      session['user'] and user_can? 'administer_system'
    else
      session['user'] and session['user'] == "admin"
    end
  end


  def user_must_have(permission)
    unauthorised_access if !session['user'] || !user_can?(permission)
  end


  def user_needs_to_be_a_user
    unauthorised_access if not session['user']
  end

  def user_needs_to_be_a_user_manager
    unauthorised_access if not user_can? 'manage_users'
  end

  def user_needs_to_be_a_user_manager_or_new_user
    unauthorised_access if session['user'] and not user_can? 'manage_users'
  end

  def user_needs_to_be_global_admin
    unauthorised_access if not user_is_global_admin?
  end

  helper_method :user_prefs
  def user_prefs
    session[:preferences] || self.class.user_preferences(session)
  end

  def user_defaults(record_type)
    default_values = user_prefs['default_values']
    DefaultValues.get(record_type) if default_values
  end

  helper_method :browse_columns
  def browse_columns
    @browse_columns ||= if session[:repo_id]
                          JSONModel::HTTP::get_json("/repositories/#{session[:repo_id]}/current_preferences")['defaults']
                        else
                          JSONModel::HTTP::get_json("/current_global_preferences")['defaults']
                        end
  end

  def user_repository_cookie
    cookies[user_repository_cookie_key]
  end

  def user_repository_cookie_key
    "#{AppConfig[:cookie_prefix]}_#{session[:user]}_repository"
  end

  def set_user_repository_cookie(repository_uri)
    cookies[user_repository_cookie_key] = repository_uri
  end

  # sometimes we get exceptions that look like this: "translation missing: validation_errors.protected_read-only_list_#/dates_of_existence/0/date_type_structured._invalid_value__add_or_update_either_a_single_or_ranged_date_subrecord_to_set_.__must_be_one_of__single__range
  # replace the untranslatable text with a generic message
  # untranslatable messages have a reference to an array index, like record/0/subrecord. We'll look for anything that has an error that matches to /d+/ and replace it with something generic that we can translate.
  def clean_exceptions(ex)
    generic_error = I18n.t("validation_errors.generic_validation_error")
    regex = /\/\d+\//

    ex.each do |key, exception|
      exception.each do |key, value|
        # value might be a string or an array of strings
        if value.is_a?(String)
          if value =~ regex
            value = generic_error
          end
        elsif value.respond_to?(:each_with_index)
          value.each_with_index do |subvalue, i|
            if subvalue =~ regex
              value[i] = generic_error
            end
          end
        end
      end
    end

    return ex
  end


  # ANW-617: To generate public URLs correctly in the show pages for various entities, we need access to the repository slug.
  # Since the JSON objects for these does not have this info, we load it into the session along with other repo data when a repo is selected for convienience.
  def self.session_repo(session, repo, repo_slug = nil)
    session[:repo] = repo
    session[:repo_id] = JSONModel(:repository).id_for(repo)

    # if the slug has been passed in, we don't need to do a DB lookup.
    # if not, we go get it so links are generated correctly after login.
    if repo_slug
      session[:repo_slug] = repo_slug
    else
      full_repo_json = JSONModel(:repository).find(session[:repo_id])
      session[:repo_slug] = full_repo_json[:slug]
    end

    self.user_preferences(session)
  end


  def self.user_preferences(session)
    session[:last_preference_refresh] = Time.now.to_i
    prefs = if session[:repo_id]
              JSONModel::HTTP::get_json("/repositories/#{session[:repo_id]}/current_preferences")['defaults']
            else
              JSONModel::HTTP::get_json("/current_global_preferences")['defaults']
            end
    session[:preferences] = prefs.reject { |k, _v|
      k.include? 'browse_column' or k.include? 'sort_column' or k.include? 'sort_direction'} if prefs
  end


  helper_method :user_can?
  def user_can?(permission, repository = nil)
    self.class.session_can?(self, permission, repository)
  end


  def self.session_can?(context, permission, repository = nil)
    repository ||= context.session[:repo]

    return false if !context.session || !context.session[:user]

    permissions_s = context.send(:cookies).signed[:archivesspace_permissions]

    if permissions_s
      # Putting this check in for backwards compatibility with the uncompressed
      # cookies.  This can be removed at a future point once everyone's running
      # with compressed cookies.
      json = if permissions_s.start_with?('ZLIB:')
               Zlib::Inflate.inflate(permissions_s[5..-1])
             else
               permissions_s
             end

      permissions = ASUtils.json_parse(json)
    else
      return false
    end

    (Permissions.user_can?(permissions, repository, permission) ||
     Permissions.user_can?(permissions, ASConstants::Repository.GLOBAL, permission))
  end


  helper_method :current_vocabulary
  def current_vocabulary
    MemoryLeak::Resources.get(:vocabulary).first.to_hash
  end


  private

  def destroy_user_session(exception)
    Thread.current[:backend_session] = nil
    Thread.current[:repo_id] = nil

    reset_session

    @message = exception.message
    return render :template => "401", :layout => nil if inline?

    flash[:error] = exception.message
    redirect_to :controller => :welcome, :action => :index, :login => true
  end


  def store_user_session
    Thread.current[:backend_session] = session[:session]
    JSONModel::set_repository(session[:repo_id])
  end


  def load_repository_list
    @repositories = MemoryLeak::Resources.get(:repository).find_all do |repository|
      user_can?('view_repository', repository.uri) || user_can?('manage_repository', repository.uri)
    end

    # Make sure the user's selected repository still exists.
    if session[:repo] && !@repositories.any? {|repo| repo.uri == session[:repo]}
      session.delete(:repo)
      session.delete(:repo_id)
    end

    if not session[:repo] and not @repositories.empty?
      if user_repository_cookie
        if @repositories.any? {|repo| repo.uri == user_repository_cookie}
          self.class.session_repo(session, user_repository_cookie)
        else
          # delete the cookie as the stored repository uri is no longer valid
          cookies.delete(user_repository_cookie_key)
        end
      else
        set_user_repository_cookie(@repositories.first.uri)
        self.class.session_repo(session, @repositories.first.uri)
      end
    end
  end


  def refresh_permissions
    if session[:last_permission_refresh] &&
        session[:last_permission_refresh] < MemoryLeak::Resources.get(:acl_system_mtime)

      User.refresh_permissions(self)
    end
  end


  def refresh_preferences
    if session[:last_preference_refresh] &&
        session[:last_preference_refresh] < MemoryLeak::Resources.get(:preferences_system_mtime)

      session[:preferences] = nil
    end
  end


  def unauthorised_access
    render_403
  end


  def account_self_service
    if !AppConfig[:allow_user_registration] && session[:user].nil?
      unauthorised_access
    end
  end

  def render_403
    return render :status => 403, :template => "403", :layout => nil if inline?

    render "/403", :status => 403
  end


  def render_404
    return render :template => "404", :layout => nil if inline?

    render "/404"
  end


  # We explicitly set the formats and handlers here to avoid the huge number of
  # stat() syscalls that Rails normally triggers when running in dev mode.
  #
  # It would have been nice to call this 'render_partial', but that name is
  # taken by the default controller.
  #
  def render_aspace_partial(args)
    defaults = {:formats => [:html], :handlers => [:erb]}
    return render(defaults.merge(args))
  end


  def init_ancestor_titles
    @ancestor_titles = {}
  end


  protected

  def cleanup_params_for_schema(params_hash, schema)
    # We're expecting a HashWithIndifferentAccess...
    if params_hash.respond_to?(:to_unsafe_hash)
      params_hash = params_hash.to_unsafe_hash
    end

    fix_arrays = proc do |hash, schema|
      result = hash.clone

      schema['properties'].each do |property, definition|
        if definition['type'] == 'array' && result[property].is_a?(Hash)
          result[property] = result[property].map {|_, v| v}
        end
      end

      result
    end


    set_false_for_checkboxes = proc do |hash, schema|
      result = hash.clone

      schema['properties'].each do |property, definition|
        if definition['type'] == 'boolean'
          if not result.has_key?(property)
            result[property] = false
          else
            result[property] = (result[property].respond_to?(:to_i) && result[property].to_i === 1)
          end
        end
      end

      result
    end


    coerce_integers = proc do |hash, schema|

      schema['properties'].each do |property, definition|
        if definition['type'] == 'integer'
          if hash.has_key?(property) && hash[property].is_a?(String)
            begin
              value = Integer(hash[property])
              if value >= 0 # exclude negative numbers for legacy reasons
                hash[property] = value
              end
            rescue ArgumentError
            end
          end
        end
      end

      hash
    end


    expand_multiple_item_linker_values = proc do |hash, schema|
      # The linker widget allows multiple items to be selected for some
      # associations.  In these cases, split up the values and create
      # separate records to be created.

      associations_to_expand = ['linked_agents', 'subjects', 'classifications']

      associations_to_expand.each do |association|
        if hash.has_key?(association)
          all_expanded = []

          hash[association].each do |linked_agent|
            if linked_agent.has_key?('ref') && linked_agent['ref'].is_a?(Array)
              linked_agent['ref'].each_with_index do |ref, i|
                expanded = linked_agent.clone
                expanded['ref'] = ref
                expanded['_resolved'] = linked_agent['_resolved'][i]
                all_expanded.push(expanded)
              end
            else
              all_expanded.push(linked_agent)
            end
          end

          hash[association] = all_expanded
        end
      end

      hash
    end


    deserialise_resolved_json_blobs = proc do |hash, schema|
      # The linker widget sends us the full blob of each record being linked
      # to as a JSON blob.  Make this available as a regular hash by walking
      # the document and deserialising these blobs.

      if hash.has_key?('_resolved') && hash['_resolved'].is_a?(String)
        hash.merge('_resolved' => ASUtils.json_parse(hash['_resolved']))
      else
        hash
      end
    end

    JSONSchemaUtils.map_hash_with_schema(params_hash,
                                         schema,
                                         [fix_arrays,
                                          set_false_for_checkboxes,
                                          deserialise_resolved_json_blobs,
                                          coerce_integers,
                                          expand_multiple_item_linker_values])
  end

  def params_for_backend_search
    backend_search_params = ["page", "q", "aq", "type", "sort", "exclude", "filter_term", "fields"]
    params_for_search = params.select {|k, v| backend_search_params.include?(k) and not v.blank?}

    params_for_search["page"] ||= 1

    if params_for_search["type"]
      params_for_search["type[]"] = Array(params_for_search["type"]).reject {|v| v.blank?}.uniq
      params_for_search.delete("type")
    end

    if params_for_search["filter_term"]
      params_for_search["filter_term[]"] = Array(params_for_search["filter_term"]).reject {|v| v.blank?}
      params_for_search.delete("filter_term")
    end

    if params_for_search["aq"]
      # Just validate it
      params_for_search["aq"] = JSONModel(:advanced_query).from_json(params_for_search["aq"]).to_json
    end

    if params_for_search["exclude"]
      params_for_search["exclude[]"] = Array(params_for_search["exclude"]).reject {|v| v.blank?}
      params_for_search.delete("exclude")
    end

    if params_for_search["fields"]
      params_for_search["fields[]"] = Array(params_for_search["fields"]).reject {|v| v.blank?}
      params_for_search.delete("fields")
    end

    params_for_search
  end

  def parse_tree(node, parent, &block)
    node['children'].map {|child_node| parse_tree(child_node, node, &block)} if node['children']
    block.call(node, parent)
  end


  def prepare_tree_nodes(node, &block)
    node['children'].map {|child_node| prepare_tree_nodes(child_node, &block) }
    block.call(node)
  end


  def handle_transfer(model)
    old_uri = model.uri_for(params[:id])
    response = JSONModel::HTTP.post_form(model.uri_for(params[:id]) + "/transfer",
                                         "target_repo" => params[:ref])

    if response.code == '200'
      flash[:success] = I18n.t("actions.transfer_successful")
    elsif response.code == '409'
    # Transfer failed for a known reason
      raise ArchivesSpace::TransferConflictException.new(ASUtils.json_parse(response.body).fetch('error'))
    else
      flash[:error] = I18n.t("actions.transfer_failed") + ": " + response.body
    end

    redirect_to(:action => :index, :deleted_uri => old_uri)
  end


  helper_method :default_advanced_search_queries
  def default_advanced_search_queries
    [{"i" => 0, "type" => "text", "comparator" => "contains"}]
  end


  helper_method :advanced_search_queries
  def advanced_search_queries
    return default_advanced_search_queries if !params["advanced"]

    indexes = params.keys.collect {|k| k[/^f(?<index>[\d]+)/, "index"]}.compact.sort {|a, b| a.to_i <=> b.to_i}

    return default_advanced_search_queries if indexes.empty?

    indexes.map {|i|
      query = {
        "i" => i.to_i,
        "op" => params["op#{i}"],
        "field" => params["f#{i}"],
        "value" => params["v#{i}"],
        "type" => params["t#{i}"]
      }

      if query["type"] == "text"
        query["comparator"] = params["top#{i}"]
        query["empty"] = query["comparator"] == "empty"
      end

      if query["op"] === "NOT"
        query["op"] = "AND"
        query["negated"] = true
      end

      if query["type"] == "date"
        query["comparator"] = params["dop#{i}"]
        query["empty"] = query["comparator"] == "empty"
      end

      if query["type"] == "boolean"
        query["value"] = query["value"] == "empty" ? "empty" : query["value"] == "true"
        query["empty"] = query["value"] == "empty"
      end

      if query["type"] == "enum"
        query["empty"] = query["value"].blank?
      end

      query
    }
  end

  def set_locale(&action)
    if session['user']
      locale = user_prefs.key?('locale') ? user_prefs['locale'].to_sym : I18n.default_locale
    else
      locale = I18n.default_locale
    end
    I18n.with_locale(locale, &action)
  end

  def current_record
    raise "method 'current_record' not implemented for controller: #{self}"
  end

  def controller_supports_current_record?
    self.method(:current_record).owner != ApplicationController
  end

  def check_required_subrecords(required, obj)
    required.each do |subrecord_field, requirements_defn|
      next unless requirements_defn.is_a?(Array)
      if obj[subrecord_field].empty?
        obj.add_error(subrecord_field, :missing_required_subrecord)
      end
    end
  end

  helper_method :current_record
  helper_method :'controller_supports_current_record?'
end