class Model < ApplicationRecord extend Memoist include PathBuilder include Followable include CaberObject include Linkable include Sluggable include PublicIDable include Commentable include Problematic include Indexable include FaspClient::DataSharing::Lifecycle broadcasts_refreshes acts_as_federails_actor( username_field: :public_id, name_field: :name, profile_url_method: :url_for, # We use the Service actor type purely so Mastodon doesn't ignore the actor. # Actual type is differentiated with f3di:concreteType == "3DModel". # Ideally this would be a Document: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document # Hopefully at some point this can change, if Mastodon starts allowing other actor types # See https://github.com/mastodon/mastodon/issues/22322 actor_type: "Service" ) fasp_share_lifecycle category: "account", uri_method: :fasp_uri, only_if: :public_and_indexable? def fasp_uri federails_actor&.federated_url end scope :recent, -> { order(created_at: :desc) } belongs_to :library belongs_to :creator, optional: true belongs_to :collection, optional: true belongs_to :preview_file, class_name: "ModelFile", optional: true has_many :model_files, dependent: :destroy acts_as_taggable_on :tags accepts_nested_attributes_for :creator has_many :list_items, as: :listable, dependent: :destroy before_validation :strip_separators_from_path, if: :path_changed? before_validation :publish_creator, if: :will_be_public? before_validation :normalize_license, if: -> { respond_to? :license } # In Rails 7.1 we will be able to do this instead: # normalizes :license, with: -> license { license.blank? ? nil : license } after_create_commit :post_creation_activity after_create :pregenerate_downloads before_update :move_files, if: :need_to_move_files? after_update_commit :post_update_activity after_update :pregenerate_downloads, if: :was_changed? after_save :write_datapackage_later, if: :was_changed? after_commit :check_for_problems_later, on: :update validates :name, presence: true, length: SAFE_NAME_LENGTH, on: [:create, :update, :single_upload] # 225 because max filename length is 255, and we use name to generate them. Also, it seems plenty. validates :path, presence: true, uniqueness: {scope: :library}, on: [:create, :update] validate :check_for_submodels, on: :update, if: :need_to_move_files? validate :destination_is_vacant, on: :update, if: :need_to_move_files? validates :license, spdx: true, allow_nil: true, if: -> { respond_to? :license } validates :public_id, multimodel_uniqueness: {punctuation_sensitive: false, case_sensitive: false, check: FederailsCommon::FEDIVERSE_USERNAMES}, if: -> { respond_to? :public_id }, on: [:create, :update] validate :validate_publishable scoped_search on: [:name, :caption] scoped_search on: :notes, aliases: [:description], only_explicit: true scoped_search relation: :library, on: :name, rename: :library, only_explicit: true, default_operator: :eq scoped_search relation: :creator, on: :name, rename: :creator scoped_search relation: :collection, on: :name, rename: :collection scoped_search relation: :tags, on: :name, default_operator: :eq, rename: :tag scoped_search relation: :model_files, on: :filename, rename: :filename, only_explicit: true scoped_search on: :path, only_explicit: true def parents Pathname.new(path).parent.descend.filter_map do |path| library.models.find_by(path: path.to_s) end end memoize :parents def was_changed? !previous_changes.empty? end def self.common_root(*models) # If there are different libraries, there is no common root return nil unless models.map(&:library_id).uniq.one? # Get each path, split, and working from the front, find the common elements first, *remainder = models.map { |it| it.path.split(File::SEPARATOR).without(".") } parts = first.zip(*remainder) common = parts.map { |it| (it.uniq.length == 1) ? it.first : nil } common = common.first(common.index(nil) || 99999) common.empty? ? nil : File.join(common) end def disjoint?(other) Model.common_root(self, other).nil? end def contains?(other) Model.common_root(self, other) == path end def adopt_file(file, path_prefix: nil) # Work out the new filename new_filename = path_prefix ? File.join(path_prefix, file.filename) : file.filename # If there's an identical file already there... existing_file = model_files.find_by(filename: new_filename) if file.digest && file.digest == existing_file&.digest file.problems.destroy_all # Remove associated problems for this file manually file.delete # Don't run callbacks, just remove the database record return elsif existing_file # Otherwise, make sure the name is distinct by adding the digest to the end if there's a clash new_filename = "#{File.basename(new_filename, ".*")}_#{file.digest.first(6)}#{File.extname(new_filename)}" end # Adopt the file file.update( filename: new_filename, model: self ) file.reattach! end def merge!(*models) # If we've got one argument and it's enumerable, use it directly models = models[0] if models.length == 1 && models[0].is_a?(Enumerable) # Go through the list models.each do |other| # Work out path to the other target from here relative_path = contains?(other) ? Pathname.new(other.path).relative_path_from(Pathname.new(path)) : nil # Remove datapackage other.datapackage&.destroy # Move files other.model_files.each { |it| adopt_file(it, path_prefix: relative_path) } check_for_problems_later # Merge metadata self.creator ||= other.creator self.collection ||= other.collection self.license ||= other.license self.caption ||= other.caption self.notes ||= other.notes self.sensitive ||= other.sensitive tag_list.add(*other.tag_list) self.links_attributes = other.links.map { |it| {url: it.url} } save! # Destroy the other model other.reload other.destroy end end def create_or_update_file_from_url(url:, filename:) uri = URI.parse(url) file = model_files.find_or_create_by(filename: filename) file.update_from_url!(url: uri.to_s) file rescue URI::InvalidURIError end def delete_from_disk_and_destroy # Remove all presupported_version relationships first, they get in the way # This will go away later when we do proper file relationships rather than linking the tables directly model_files.update_all(presupported_version_id: nil) # rubocop:disable Rails/SkipsModelValidations # Trigger deletion for each file separately, to make sure cleanup happens model_files.each { |f| f.delete_from_disk_and_destroy } # Remove tags first - sometimes this causes problems if we don't do it beforehand update!(tags: []) # Delete directory corresponding to model library.storage.delete_prefixed(path) # Remove from DB destroy end def contained_models previous_library.models.where( Model.arel_table[:path].matches( Model.sanitize_sql_like(previous_path) + "/%", "\\" ) ) end def contains_other_models? contained_models.exists? end def needs_organizing? formatted_path != path end def new? tags.where(name: SiteSettings.model_tags_auto_tag_new).any? end def valid_preview_files model_files.select { |it| it.is_image? || it.is_renderable? } end def image_files model_files.select(&:is_image?) end def three_d_files model_files.select(&:is_3d_model?) end def exists_on_storage? library.has_folder?(path) end def organize! autoupdate_path save! end def self.create_from(other, link_preview_file: false, name: nil, path: nil) new_model = other.dup new_model.update( path: path, name: name || "Copy of #{other.name}", public_id: nil, tags: other.tags, preview_file: link_preview_file ? other.preview_file : nil ) path ? new_model.save! : new_model.organize! # Wipe permissions and copy from old model new_model.caber_relations.delete_all new_model.update!( caber_relations_attributes: other.caber_relations.all.map { |it| {permission: it.permission, subject: it.subject} } ) new_model end def split!(files: []) preview_file_will_move = files.include?(preview_file) new_model = Model.create_from(self, link_preview_file: preview_file_will_move) # Clear preview file if it was moved update!(preview_file: nil) if preview_file_will_move # Move files files.each { |it| new_model.adopt_file(it) } # Done! new_model end def has_supported_and_unsupported? model_files.exists?(presupported: true) && model_files.exists?(presupported: false) end def file_extensions model_files.map(&:extension).uniq end def size_on_disk model_files.pluck(:size).compact.sum end def to_activitypub_object ActivityPub::ModelSerializer.new(self).serialize end def add_new_files_later(include_all_subfolders: false, delay: 0.seconds) Scan::Model::AddNewFilesJob.set(wait: delay).perform_later(id, include_all_subfolders: include_all_subfolders) end def check_later(delay: 0.seconds) Scan::CheckModelJob.set(wait: delay).perform_later(id) end def check_for_problems_later(delay: 5.seconds) Scan::Model::CheckForProblemsJob.set(wait: delay).perform_later(id) end def organize_later(delay: 5.seconds) OrganizeModelJob.set(wait: delay).perform_later(id) end def write_datapackage_later(delay: 1.second) UpdateDatapackageJob.set(wait: delay).perform_later(id) end def parse_metadata_later(delay: 0.seconds) Scan::Model::ParseMetadataJob.set(wait: delay).perform_later(id) end def datapackage model_files.find_by(filename: "datapackage.json") end def datapackage_content JSON.parse(datapackage.attachment.read) unless datapackage.nil? rescue Shrine::FileNotFound end def pregenerate_downloads(delay: 10.minutes, queue: nil) # By default, give 10 minutes' grace for followup changes before we pregenerate the download # Other scan jobs could be running, which might take some time. # This is brittle, and we need a better way to say "this model is done changing for a while" return unless SiteSettings.pregenerate_downloads download_types = [nil] download_types += ["supported", "unsupported"] if has_supported_and_unsupported? download_types += file_extensions.excluding("json") download_types.each do |selection| ArchiveDownloadService.new(model: self, selection: selection).prepare(delay: delay, queue: queue) end end private def normalize_license self.license = nil if license.blank? end def strip_separators_from_path self.path = path&.trim_path_separators end def previous_library library_id_changed? ? Library.find_by(id: library_id_was) : library end def previous_path path_changed? ? path_was : path end def need_to_move_files? library_id_changed? || (path_changed? && (previous_path.trim_path_separators != path.trim_path_separators) ) end def autoupdate_path self.path = formatted_path end def check_for_submodels if contains_other_models? errors.add(library_id_changed? ? :library : :path, :nested) end end def destination_is_vacant if exists_on_storage? && need_to_move_files? errors.add(:path, :destination_exists) end end def move_files # Move all the files model_files.each(&:reattach!) # Recreate derivatives in new place model_files.each(&:create_derivatives!) # Remove the old folder if it's still there previous_library.storage.delete_prefixed(previous_path) end def post_creation_activity Activity::ModelPublishedJob.set(wait: 5.seconds).perform_later(id) if public? end def post_update_activity if creator_previously_changed? && creator&.public? Activity::ModelPublishedJob.set(wait: 5.seconds).perform_later(id) elsif collection_previously_changed? && collection&.public? Activity::ModelCollectedJob.set(wait: 5.seconds).perform_later(id, collection.id) elsif just_became_public? Activity::ModelPublishedJob.set(wait: 5.seconds).perform_later(id) elsif public? && noteworthy_change? Activity::ModelUpdatedJob.set(wait: 5.seconds).perform_later(id) end end def noteworthy_change? # Exclude internal fields, they're not interesting enough to post comments for !previous_changes.keys.without([ "id", "path", "library_id", "created_at", "updated_at", "preview_file_id", "slug", "public_id", "name_lower" ]).empty? end def validate_publishable # If the model will be public return unless will_be_public? # Check required fields errors.add :license, :blank if license.nil? errors.add :creator, :blank if creator.nil? errors.add :creator, :private if creator && !creator.public? end def publish_creator creator&.update!(caber_relations_attributes: [{permission: "view", subject: nil}]) unless creator&.public? end end