# frozen_string_literal: true class Asset < ApplicationRecord include SearchableModel include DatabaseHelper include Encryptor include WopiUtil include ActiveStorageFileUtil include ActiveStorageConcerns require 'tempfile' # Lock duration set to 30 minutes LOCK_DURATION = 60 * 30 enum view_mode: { thumbnail: 0, list: 1, inline: 2 } # ActiveStorage configuration has_one_attached :file has_one_attached :file_pdf_preview has_one_attached :preview_image # Asset validation # This could cause some problems if you create empty asset and want to # assign it to result validate :step_or_result_or_repository_asset_value validate :wopi_filename_valid, on: :wopi_file_creation validate :check_file_size, on: :on_api_upload belongs_to :created_by, foreign_key: 'created_by_id', class_name: 'User', optional: true belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User', optional: true belongs_to :team, optional: true has_one :step_asset, inverse_of: :asset, dependent: :destroy has_one :step, through: :step_asset, touch: true, dependent: :nullify has_one :result_asset, inverse_of: :asset, dependent: :destroy has_one :result, through: :result_asset, touch: true, dependent: :nullify has_one :repository_asset_value, inverse_of: :asset, dependent: :destroy has_one :repository_cell, through: :repository_asset_value, dependent: :nullify has_many :report_elements, inverse_of: :asset, dependent: :destroy has_one :asset_text_datum, inverse_of: :asset, dependent: :destroy scope :sort_assets, lambda { |sort_value = 'new'| sort = case sort_value when 'old' then { created_at: :asc } when 'atoz' then { 'active_storage_blobs.filename': :asc } when 'ztoa' then { 'active_storage_blobs.filename': :desc } else { created_at: :desc } end joins(file_attachment: :blob).order(sort) } attr_accessor :file_content, :file_info, :in_template before_save :reset_file_processing, if: -> { file.new_record? } def self.search( user, include_archived, query = nil, page = 1, _current_team = nil, options = {} ) teams = user.teams.select(:id) assets_in_steps = Asset.joins(:step).where( 'steps.id IN (?)', Step.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .select(:id) ).pluck(:id) assets_in_results = Asset.joins(:result).where( 'results.id IN (?)', Result.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT) .select(:id) ).pluck(:id) assets_in_inventories = Asset.joins( repository_cell: { repository_column: :repository } ).where('repositories.team_id IN (?)', teams).pluck(:id) assets = Asset.distinct .where('assets.id IN (?) OR assets.id IN (?) OR assets.id IN (?)', assets_in_steps, assets_in_results, assets_in_inventories) new_query = Asset.left_outer_joins(:asset_text_datum) .joins(file_attachment: :blob) .from(assets, 'assets') a_query = s_query = '' if options[:whole_word].to_s == 'true' || options[:whole_phrase].to_s == 'true' like = options[:match_case].to_s == 'true' ? '~' : '~*' s_query = query.gsub(/[!()&|:]/, ' ') .strip .split(/\s+/) .map { |t| t + ':*' } if options[:whole_word].to_s == 'true' a_query = query.split .map { |a| Regexp.escape(a) } .join('|') s_query = s_query.join('|') else a_query = Regexp.escape(query) s_query = s_query.join('&') end a_query = '\\y(' + a_query + ')\\y' s_query = s_query.tr('\'', '"') new_query = new_query.where( "(active_storage_blobs.filename #{like} ? " \ "OR asset_text_data.data_vector @@ to_tsquery(?))", a_query, s_query ) else like = options[:match_case].to_s == 'true' ? 'LIKE' : 'ILIKE' a_query = query.split.map { |a| "%#{sanitize_sql_like(a)}%" } # Trim whitespace and replace it with OR character. Make prefixed # wildcard search term and escape special characters. # For example, search term 'demo project' is transformed to # 'demo:*|project:*' which makes word inclusive search with postfix # wildcard. s_query = query.gsub(/[!()&|:]/, ' ') .strip .split(/\s+/) .map { |t| t + ':*' } .join('|') .tr('\'', '"') new_query = new_query.where( "(active_storage_blobs.filename #{like} ANY (array[?]) " \ "OR asset_text_data.data_vector @@ to_tsquery(?))", a_query, s_query ) end # Show all results if needed if page != Constants::SEARCH_NO_LIMIT new_query = new_query.select('assets.*, asset_text_data.data AS data') .limit(Constants::SEARCH_LIMIT) .offset((page - 1) * Constants::SEARCH_LIMIT) Asset.select( "assets_search.*, ts_headline(assets_search.data, to_tsquery('" + sanitize_sql_for_conditions(s_query) + "'), 'StartSel=, StopSel=') AS headline" ).from(new_query, 'assets_search') else new_query end end def blob file&.blob end def previewable? return false unless preview_image.attached? || file.attached? previewable_document?(blob) || previewable_image? end def preview_attachment preview_image.attached? ? preview_image : file end def medium_preview preview_attachment.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT) end def large_preview preview_attachment.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT) end def file_name return '' unless file.attached? file.blob&.filename&.sanitized end def preview_image_file_name return '' unless preview_image.attached? preview_image.blob&.filename&.sanitized end def render_file_name if file.attached? && file.metadata['asset_type'] file.metadata['name'] else file_name end end def file_size return 0 unless file.attached? file.blob&.byte_size end def content_type return '' unless file.attached? file&.blob&.content_type end def duplicate new_asset = dup return unless new_asset.save duplicate_file(new_asset) new_asset end def duplicate_file(to_asset) return unless file.attached? raise ArgumentError, 'Destination asset should be persisted first!' unless to_asset.persisted? file.blob.open do |tmp_file| to_blob = ActiveStorage::Blob.create_and_upload!(io: tmp_file, filename: blob.filename, metadata: blob.metadata) to_asset.file.attach(to_blob) end if preview_image.attached? preview_image.blob.open do |tmp_preview_image| to_blob = ActiveStorage::Blob.create_and_upload!( io: tmp_preview_image, filename: blob.filename, metadata: blob.metadata ) to_asset.preview_image.attach(to_blob) end end to_asset.post_process_file(to_asset.team) end def image? content_type =~ %r{^image/#{Regexp.union(Constants::WHITELISTED_IMAGE_TYPES)}} end def text? Constants::TEXT_EXTRACT_FILE_TYPES.any? do |v| file&.blob&.content_type&.start_with? v end end def marvinjs? file.metadata[:asset_type] == 'marvinjs' end def pdf_preview_ready? return false if pdf_preview_processing return true if file_pdf_preview.attached? PdfPreviewJob.perform_later(id) ActiveRecord::Base.no_touching { update(pdf_preview_processing: true) } false end def pdf? content_type == 'application/pdf' end def pdf_previewable? pdf? || (previewable_document?(blob) && Rails.application.config.x.enable_pdf_previews) end def post_process_file(team = nil) # Extract asset text if it's of correct type if text? Rails.logger.info "Asset #{id}: Creating extract text job" # The extract_asset_text also includes # estimated size calculation Delayed::Job.enqueue(AssetTextExtractionJob.new(id, in_template)) elsif marvinjs? extract_asset_text else # Update asset's estimated size immediately update_estimated_size(team) end if Rails.application.config.x.enable_pdf_previews && previewable_document?(blob) PdfPreviewJob.perform_later(id) ActiveRecord::Base.no_touching { update(pdf_preview_processing: true) } end end def extract_asset_text(in_template = false) self.in_template = in_template if marvinjs? mjs_doc = Nokogiri::XML(file.metadata[:description]) mjs_doc.remove_namespaces! text_data = mjs_doc.search("//Field[@name='text']").collect(&:text).join(' ') else blob.open do |tmp_file| text_data = Yomu.new(tmp_file.path).text end end if asset_text_datum.present? # Update existing text datum if it exists asset_text_datum.update(data: text_data) else # Create new text datum AssetTextDatum.create(data: text_data, asset: self) end Rails.logger.info "Asset #{id}: Asset file successfully extracted" # Finally, update asset's estimated size to include # the data vector update_estimated_size(team) rescue StandardError => e Rails.logger.fatal( "Asset #{id}: Error extracting contents from asset "\ "file #{file.blob.key}: #{e.message}" ) end # If team is provided, its space_taken # is updated as well def update_estimated_size(team = nil) return if file_size.blank? || in_template es = file_size if asset_text_datum.present? && asset_text_datum.persisted? asset_text_datum.reload es += get_octet_length_record(asset_text_datum, :data) es += get_octet_length_record(asset_text_datum, :data_vector) end es *= Constants::ASSET_ESTIMATED_SIZE_FACTOR update(estimated_size: es) Rails.logger.info "Asset #{id}: Estimated size successfully calculated" # Finally, update team's space if team.present? team.take_space(es) team.save end end def can_perform_action(action) if ENV['WOPI_ENABLED'] == 'true' file_ext = file_name.split('.').last if file_ext == 'wopitest' && (!ENV['WOPI_TEST_ENABLED'] || ENV['WOPI_TEST_ENABLED'] == 'false') return false end action = get_action(file_ext, action) return false if action.nil? true else false end end def get_action_url(user, action, with_tokens = true) file_ext = file_name.split('.').last action = get_action(file_ext, action) if !action.nil? action_url = action[:urlsrc] if ENV['WOPI_BUSINESS_USERS'] && ENV['WOPI_BUSINESS_USERS'] == 'true' action_url = action_url.gsub(//, 'IsLicensedUser=1&') action_url = action_url.gsub(//, 'IsLicensedUser=1') else action_url = action_url.gsub(//, 'IsLicensedUser=0&') action_url = action_url.gsub(//, 'IsLicensedUser=0') end action_url = action_url.gsub(/<.*?=.*?>/, '') rest_url = Rails.application.routes.url_helpers.wopi_rest_endpoint_url( host: ENV['WOPI_ENDPOINT_URL'], id: id ) action_url += "WOPISrc=#{rest_url}" if with_tokens token = user.get_wopi_token action_url + "&access_token=#{token.token}"\ "&access_token_ttl=#{(token.ttl * 1000)}" else action_url end else return nil end end def favicon_url(action) file_ext = file_name.split('.').last action = get_action(file_ext, action) action[:icon] if action[:icon] end # locked?, lock_asset and refresh_lock rely on the asset # being locked in the database to prevent race conditions def locked? unlock_expired !lock.nil? end def lock_asset(lock_string) self.lock = lock_string self.lock_ttl = Time.now.to_i + LOCK_DURATION save! end def refresh_lock self.lock_ttl = Time.now.to_i + LOCK_DURATION save! end def unlock self.lock = nil self.lock_ttl = nil save! end def unlock_expired with_lock do if !lock_ttl.nil? && lock_ttl < Time.now.to_i self.lock = nil self.lock_ttl = nil save! end end end def update_contents(new_file) file.attach(io: new_file, filename: file_name) self.version = version.nil? ? 1 : version + 1 save end def editable_image? !locked? && (%r{^image/#{Regexp.union(Constants::WHITELISTED_IMAGE_TYPES_EDITABLE)}} =~ file.content_type).present? end def generate_base64(style) return convert_variant_to_base64(medium_preview) if style == :medium end def my_module (result || step)&.my_module end private def tempdir Rails.root.join('tmp') end def previewable_image? preview_image.attached? || file.blob&.content_type&.match?(%r{^image/#{Regexp.union(Constants::WHITELISTED_IMAGE_TYPES)}}) end def step_or_result_or_repository_asset_value # We must allow both step and result to be blank because of GUI # (even though it's not really a "valid" asset) if step.present? && result.present? || step.present? && repository_asset_value.present? || result.present? && repository_asset_value.present? errors.add( :base, 'Asset can only be result or step or repository cell, not ever.' ) end end def wopi_filename_valid # Check that filename without extension is not blank if file_name[0..-6].blank? errors.add( :file, I18n.t('general.text.not_blank') ) end # Check maximum filename length if file_name.length > Constants::FILENAME_MAX_LENGTH errors.add( :file, I18n.t( 'general.file.file_name_too_long', limit: Constants::FILENAME_MAX_LENGTH ) ) end end def check_file_size if file.attached? if file.blob.byte_size > Rails.application.config.x.file_max_size_mb.megabytes errors.add(:file, I18n.t('activerecord.errors.models.asset.attributes.file.too_big')) end end end def reset_file_processing self.file_processing = false end end