mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-11-14 05:05:55 +08:00
516 lines
14 KiB
Ruby
516 lines
14 KiB
Ruby
# 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
|
|
|
|
# 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=<mark>, StopSel=</mark>') AS headline"
|
|
).from(new_query, 'assets_search')
|
|
else
|
|
new_query
|
|
end
|
|
end
|
|
|
|
def blob
|
|
file&.blob
|
|
end
|
|
|
|
def previewable?
|
|
return false unless file.attached?
|
|
|
|
previewable_document?(blob) || previewable_image?
|
|
end
|
|
|
|
def medium_preview
|
|
file.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT)
|
|
end
|
|
|
|
def large_preview
|
|
file.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT)
|
|
end
|
|
|
|
def file_name
|
|
return '' unless file.attached?
|
|
|
|
file.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
|
|
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 bio_eddie?
|
|
file.metadata[:asset_type] == 'bio_eddie' || File.extname(file_name) == '.helm'
|
|
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
|
|
Asset.delay(queue: :assets).extract_asset_text_delayed(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 self.extract_asset_text_delayed(asset_id, in_template = false)
|
|
asset = find_by(id: asset_id)
|
|
return unless asset.present? && asset.file.attached?
|
|
|
|
asset.extract_asset_text(in_template)
|
|
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
|
|
# Start Tika as a server
|
|
Yomu.server(:text)
|
|
begin
|
|
blob.open do |tmp_file|
|
|
text_data = Yomu.new(tmp_file.path).text
|
|
end
|
|
ensure
|
|
Yomu.kill_server!
|
|
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=BUSINESS_USER&>/,
|
|
'IsLicensedUser=1&')
|
|
action_url = action_url.gsub(/<IsLicensedUser=BUSINESS_USER>/,
|
|
'IsLicensedUser=1')
|
|
else
|
|
action_url = action_url.gsub(/<IsLicensedUser=BUSINESS_USER&>/,
|
|
'IsLicensedUser=0&')
|
|
action_url = action_url.gsub(/<IsLicensedUser=BUSINESS_USER>/,
|
|
'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?
|
|
file.blob&.content_type =~ %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
|