mysociety/alaveteli

View on GitHub
app/controllers/attachments_controller.rb

Summary

Maintainability
B
4 hrs
Test Coverage
##
# Controller to serve FoiAttachment records in both raw and as HTML.
#
class AttachmentsController < ApplicationController
  include FragmentCachable
  include InfoRequestHelper
  include PublicTokenable

  skip_before_action :html_response, :store_gettext_locale

  before_action :find_info_request, :find_incoming_message, :find_attachment
  before_action :find_project

  include ProminenceHeaders

  before_action :authenticate_attachment
  before_action :authenticate_attachment_as_html, only: :show_as_html
  before_action :set_cors

  around_action :ensure_masked
  around_action :cache_attachments, only: :show_as_html

  def show
    render body: @attachment.body, content_type: content_type
  end

  def show_as_html
    # images made during conversion (e.g. images in PDF files) are put in the
    # cache directory, so the same cache code in cache_attachments above will
    # display them.
    image_dir = File.dirname(cache_key_path)
    FileUtils.mkdir_p(image_dir)

    html = @attachment.body_as_html(
      image_dir,
      attachment_url: Rack::Utils.escape(attachment_url(@attachment)),
      content_for: {
        head_suffix: render_to_string(
          partial: 'request/view_html_stylesheet',
          formats: [:html]
        ),
        body_prefix: render_to_string(
          partial: 'request/view_html_prefix',
          formats: [:html]
        )
      }
    )

    html = @incoming_message.apply_masks(html, response.media_type)

    render html: html.html_safe
  end

  private

  def find_info_request
    @info_request =
      if public_token?
        InfoRequest.find_by!(public_token: public_token)
      else
        InfoRequest.find_by!(url_title: params[:request_url_title])
      end
  end

  def find_incoming_message
    @incoming_message = @info_request.incoming_messages.find(
      params[:incoming_message_id]
    )
  end

  def find_attachment
    @attachment = (
      @incoming_message.parse_raw_email!

      IncomingMessage.get_attachment_by_url_part_number_and_filename!(
        @incoming_message.get_attachments_for_display,
        part_number,
        original_filename
      )
    )
  end

  def find_project
    return unless current_user && params[:project_id]

    @project = current_user.projects.find_by(id: params[:project_id])
  end

  def authenticate_attachment
    # Test for hidden
    if cannot?(:read, @info_request)
      request.format = :html
      return render_hidden
    end
    if cannot?(:read, @incoming_message)
      request.format = :html
      return render_hidden('request/hidden_correspondence')
    end

    if @attachment
      if cannot?(:read, @attachment)
        request.format = :html
        render_hidden('request/hidden_attachment')
      end
    elsif params[:file_name]
      # If we can't find the right attachment, redirect to the incoming message:
      redirect_to incoming_message_url(@incoming_message), status: 303
    else
      render plain: 'Directory listing not allowed', status: 403
    end
  end

  def authenticate_attachment_as_html
    # The conversion process can generate files in the cache directory that can
    # be served up directly by the webserver according to httpd.conf, so don't
    # allow it unless that's OK.
    return if attachment_is_public?

    raise ActiveRecord::RecordNotFound, 'Attachment HTML not found.'
  end

  def ensure_masked
    if @attachment.masked?
      yield
    else
      FoiAttachmentMaskJob.perform_later(@attachment)

      Timeout.timeout(5) do
        until @attachment.masked?
          sleep 0.5
          @attachment.reload
        end
        redirect_to(request.fullpath)
      end
    end

  rescue Timeout::Error, ActiveRecord::RecordNotFound
    redirect_to wait_for_attachment_mask_path(
      @attachment.to_signed_global_id,
      referer: verifier.generate(request.fullpath)
    )
  end

  def set_cors
    # Allow CSVs to be explored in a Datasette instance
    return unless @attachment.content_type == 'text/csv'

    headers['Access-Control-Allow-Origin'] = '*'
    headers['Access-Control-Request-Method'] = 'GET'
  end

  # special caching code so mime types are handled right
  def cache_attachments
    if !params[:skip_cache].nil?
      yield
    else
      if foi_fragment_cache_exists?(cache_key_path)
        logger.info("Reading cache for #{cache_key_path}")

        render body: foi_fragment_cache_read(cache_key_path),
               content_type: content_type
        return
      end

      yield

      if params[:skip_cache].nil? && response.status == 200
        # write it to the filesystem ourselves, so is just a plain file. (The
        # various fragment cache functions using Ruby Marshall to write the file
        # which adds a header, so isn't compatible with images that have been
        # extracted elsewhere from PDFs)
        if attachment_is_cacheable?
          logger.info("Writing cache for #{cache_key_path}")
          foi_fragment_cache_write(cache_key_path, response.body)
        end
      end
    end
  end

  def part_number
    params[:part].to_i
  end

  def original_filename
    filename = params[:file_name]
    return unless filename

    if action_name == 'show_as_html'
      filename.gsub(/\.html$/, '')
    else
      filename
    end
  end

  def content_type
    # we don't use @attachment.content_type here, as we want same mime type
    # when cached in cache_attachments above
    AlaveteliFileTypes.filename_to_mimetype(params[:file_name]) ||
      'application/octet-stream'
  end

  def attachment_is_public?
    # If this a request, message and attachment are public then it can be served
    # up without authentication
    prominence.is_public? &&
      @incoming_message.is_public? &&
      @attachment.is_public?
  end

  def attachment_is_cacheable?
    # If this a request, message and attachment are searchable then we can cache
    # as there are no custom response headers (EG X-Robots-Tag)
    prominence.is_searchable? &&
      @incoming_message.indexed_by_search? &&
      @attachment.indexed_by_search?
  end

  def cache_key_path
    foi_fragment_cache_path(
      id: @info_request.id,
      incoming_message_id: @incoming_message.id,
      part: part_number,
      file_name: original_filename,
      locale: false
    )
  end

  def current_ability
    @current_ability ||= Ability.new(
      current_user, project: @project, public_token: public_token?
    )
  end

  def prominence
    @info_request.prominence(decorate: true)
  end

  def with_prominence
    @info_request
  end

  def verifier
    Rails.application.message_verifier('AttachmentsController')
  end
end