class Library < ApplicationRecord extend Memoist include PublicIDable include Problematic STORAGE_SERVICES = [ "filesystem", "s3" ] has_many :models, dependent: :destroy has_many :model_files, through: :models serialize :tag_regex, type: Array, coder: YAML after_initialize :init before_validation :ensure_path_case_is_correct after_destroy :reset_default_library after_commit :register_storage, on: :create normalizes :path, with: ->(path) do Pathname.new(path.strip).realpath.to_s rescue Errno::ENOENT, Errno::EACCES # carry on, we validate these later path.strip end validates :storage_service, presence: true, inclusion: STORAGE_SERVICES validates :path, presence: true, uniqueness: true, safe_path: true, read_write: true, disjoint_library_folder: true, if: -> { storage_service == "filesystem" } validates :s3_bucket, presence: true, if: -> { storage_service == "s3" } validates :s3_region, presence: true, if: -> { storage_service == "s3" } validates :s3_access_key_id, presence: true, if: -> { storage_service == "s3" } validates :s3_secret_access_key, presence: true, if: -> { storage_service == "s3" } default_scope { order(:name) } def name self[:name] || (path ? File.basename(path) : "") end def init self.name = nil if name == "" end # Backwards compatibility for use during migrations def storage_service attributes["storage_service"] || "filesystem" end def storage_exists? case storage_service when "filesystem" Dir.exist?(path) when "s3" storage.bucket.exists? else raise "Invalid storage service: #{storage_service}" end end def free_space case storage_service when "filesystem" stat = Sys::Filesystem.stat(path) stat.bytes_available when "s3" nil else raise "Invalid storage service: #{storage_service}" end end def storage_key :"library_#{id}" end def storage_origin case storage_service when "s3" URI.parse(storage.presign(nil)[:url]).origin end end memoize :storage_origin def storage case storage_service when "filesystem" Shrine::Storage::FileSystem.new(path) when "s3" Shrine::Storage::S3.new( endpoint: s3_endpoint, bucket: s3_bucket, region: s3_region, access_key_id: s3_access_key_id, secret_access_key: s3_secret_access_key, force_path_style: s3_endpoint.present? && s3_path_style, use_accelerate_endpoint: s3_endpoint.blank? ) else raise "Invalid storage service: #{storage_service}" end end memoize :storage def register_storage ModelFileUploader.storages[storage_key] = storage end def self.register_all_storage find_each(&:register_storage) rescue ActiveRecord::StatementInvalid, NameError nil # migrations probably haven't run yet to create library table end def list_files(pattern, flags = 0) files = case storage_service when "filesystem" Dir.glob(pattern, flags, base: path).filter { |it| File.file?(File.join(path, it)) } when "s3" keys = [] pattern_array = [pattern].flatten storage.bucket.objects.each do |object| keys << object.key if pattern_array.any? { |p| File.fnmatch?(p, object.key) } end keys else raise "Invalid storage service: #{storage_service}" end # Filter out files that should be ignored files.uniq.reject { |str| SiteSettings.ignored_file?(str) } end def has_file?(path) storage.exists?(path) end def has_folder?(path) case storage_service when "s3" storage.bucket.objects(prefix: path).count > 0 else storage.exists?(path) end end def file_last_modified(file) case storage_service when "filesystem" File.mtime(File.join(path, file)) when "s3" storage.bucket.object(file).last_modified else raise "Invalid storage service: #{storage_service}" end end def create_path_if_not_on_disk=(val) if val == "1" && storage_service == "filesystem" && path.presence begin FileUtils.makedirs(path) rescue Errno::EROFS, Errno::EACCES errors.add(:path, :non_writable) end end end def self.default Library.find_by(id: SiteSettings.default_library) || Library.first end def default? SiteSettings.default_library == id end def make_default SiteSettings.default_library = id end def reset_default_library new_default = Library.where.not(id: id).first # rubocop:disable Pundit/UsePolicyScope new_default ? new_default.make_default : SiteSettings.default_library = nil end def detect_filesystem_changes_later(delay: 0.seconds) Scan::Library::DetectFilesystemChangesJob.set(wait: delay).perform_later(id) end def create_model_from_path_later(path, delay: 0.seconds) Scan::Library::CreateModelFromPathJob.set(wait: delay).perform_later(id, path) end private def ensure_path_case_is_correct # On case-preserving-and-insensitive filesystems (i.e. macOS) # if you get the case wrong, the library can be created, but then # models will get the wrong paths. This method makes sure that the # case is stored in the canonical form that the OS will give us back # in globs if storage_service == "filesystem" && path normalised = Dir.glob(path).first self.path = normalised if normalised end end end