require 'fileutils' class Skin < ApplicationRecord include HtmlCleaner include CssCleaner include SkinCacheHelper include SkinWizard TYPE_OPTIONS = [ [ts("Site Skin"), "Skin"], [ts("Work Skin"), "WorkSkin"], ] # any media types that are not a single alphanumeric word have to be specially # handled in get_media_for_filename/parse_media_from_filename MEDIA = %w(all screen handheld speech print braille embossed projection tty tv) + [ "only screen and (max-width: 42em)", "only screen and (max-width: 62em)", "(prefers-color-scheme: dark)", "(prefers-color-scheme: light)" ] IE_CONDITIONS = %w(IE IE5 IE6 IE7 IE8 IE9 IE8_or_lower) ROLES = %w(user override) ROLE_NAMES = {"user" => "add on to archive skin", "override" => "replace archive skin entirely"} # We don't show some roles to users ALL_ROLES = ROLES + %w(admin translator site) DEFAULT_ROLE = "user" DEFAULT_ROLES_TO_INCLUDE = %w(user override site) DEFAULT_MEDIA = ["all"] SKIN_PATH = 'stylesheets/skins/' SITE_SKIN_PATH = 'stylesheets/site/' belongs_to :author, class_name: 'User' has_many :preferences serialize :media, type: Array, coder: YAML, yaml: { permitted_classes: [String] } # a skin can be both parent and child has_many :skin_parents, foreign_key: 'child_skin_id', class_name: 'SkinParent', dependent: :destroy, inverse_of: :child_skin has_many :parent_skins, -> { order("skin_parents.position ASC") }, through: :skin_parents, inverse_of: :child_skins has_many :skin_children, foreign_key: 'parent_skin_id', class_name: 'SkinParent', dependent: :destroy, inverse_of: :parent_skin has_many :child_skins, through: :skin_children, inverse_of: :parent_skins accepts_nested_attributes_for :skin_parents, allow_destroy: true, reject_if: proc { |attrs| attrs[:position].blank? || (attrs[:parent_skin_title].blank? && attrs[:parent_skin_id].blank?) } has_one_attached :icon do |attachable| attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 }) end # i18n-tasks-use t("errors.attributes.icon.invalid_format") # i18n-tasks-use t("errors.attributes.icon.too_large") validates :icon, attachment: { allowed_formats: %r{image/\S+}, maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes } after_save :skin_invalidate_cache def skin_invalidate_cache skin_chooser_expire_cache skin_cache_version_update(id) # Work skins can't have children, but site skins (which have type nil) # might have children that need expiration: return unless type.nil? SkinParent.get_all_child_ids(id).each do |child_id| skin_cache_version_update(child_id) end end validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX, too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX) validates_length_of :description, allow_blank: true, maximum: ArchiveConfig.SUMMARY_MAX, too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX) validates_length_of :css, allow_blank: true, maximum: ArchiveConfig.CONTENT_MAX, too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX) before_validation :clean_media def clean_media # handle bizarro cucumber-only error that prevents media from deserializing correctly when attachments are made if media && media.is_a?(Array) && !media.empty? new_media = media.flatten.compact.collect {|m| m.gsub(/\["(\w+)"\]/, '\1')} self.media = new_media end end validate :valid_media def valid_media if media && media.is_a?(Array) && media.any? {|m| !MEDIA.include?(m)} errors.add( :base, :invalid_media, media: media.join(", ") ) end end validates :ie_condition, inclusion: {in: IE_CONDITIONS, allow_nil: true, allow_blank: true} validates :role, inclusion: {in: ALL_ROLES, allow_blank: true, allow_nil: true } validate :valid_public_preview def valid_public_preview return true if self.official? || !self.public? || self.icon.attached? errors.add(:base, :no_public_preview) end validates :title, presence: true, uniqueness: { case_sensitive: false } validate :allowed_title def allowed_title return true unless self.title.match(/archive/i) authorized_roles = if self.is_a?(WorkSkin) %w[superadmin support] else %w[superadmin] end return true if (User.current_user.roles & authorized_roles).present? errors.add(:base, :archive_in_title) end validates_numericality_of :margin, :base_em, allow_nil: true validate :valid_font def valid_font return if self.font.blank? self.font.split(',').each do |subfont| if sanitize_css_font(subfont).blank? errors.add(:font, "cannot use #{subfont}.") end end end validate :valid_colors def valid_colors if !self.background_color.blank? && sanitize_css_value(self.background_color).blank? errors.add(:background_color, "uses a color that is not allowed.") end if !self.foreground_color.blank? && sanitize_css_value(self.foreground_color).blank? errors.add(:foreground_color, "uses a color that is not allowed.") end end validate :clean_css def clean_css return if self.css.blank? self.css = clean_css_code(self.css) end scope :public_skins, -> { where(public: true) } scope :approved_skins, -> { where(official: true, public: true) } scope :unapproved_skins, -> { where(public: true, official: false, rejected: false) } scope :rejected_skins, -> { where(public: true, official: false, rejected: true) } scope :site_skins, -> { where(type: nil) } scope :wizard_site_skins, -> { where("type IS NULL AND ( margin IS NOT NULL OR background_color IS NOT NULL OR foreground_color IS NOT NULL OR font IS NOT NULL OR base_em IS NOT NULL OR paragraph_margin IS NOT NULL OR headercolor IS NOT NULL OR accent_color IS NOT NULL ) ") } def self.cached where(cached: true) end def self.in_chooser where(in_chooser: true) end def self.featured where(featured: true) end def self.approved_or_owned_by(user = User.current_user) if user.nil? approved_skins else approved_or_owned_by_any([user]) end end def self.approved_or_owned_by_any(users) where("(public = 1 AND official = 1) OR author_id in (?)", users.map(&:id)) end def self.usable where(unusable: false) end def self.sort_by_recent order("updated_at DESC") end def self.sort_by_recent_featured order("featured DESC, updated_at DESC") end def approved_or_owned_by?(user) self.public? && self.official? || author_id == user.id end def remove_me_from_preferences Preference.where(skin_id: self.id).update_all(skin_id: AdminSetting.default_skin_id) end def editable? if self.filename.present? return false elsif self.official && self.public return true if User.current_user.is_a? Admin elsif self.author == User.current_user return true else return false end end def byline if self.author.is_a? User author.login else ArchiveConfig.APP_SHORT_NAME end end def wizard_settings? margin.present? || font.present? || background_color.present? || foreground_color.present? || base_em.present? || paragraph_margin.present? || headercolor.present? || accent_color.present? end # create the minimal number of files we can, containing all the css for this entire skin def cache! self.clear_cache! self.public = true self.official = true save! css_to_cache = "" last_role = "" file_count = 1 skin_dir = Skin.skins_dir + skin_dirname FileUtils.mkdir_p skin_dir (get_all_parents + [self]).each do |next_skin| if next_skin.get_sheet_role != last_role # save to file if css_to_cache.present? cache_filename = skin_dir + "#{file_count}_#{last_role}.css" file_count+=1 File.open(cache_filename, 'w') {|f| f.write(css_to_cache)} css_to_cache = "" end last_role = next_skin.get_sheet_role end css_to_cache += next_skin.get_css end # TODO this repetition is all wrong but my brain is fried if css_to_cache.present? cache_filename = skin_dir + "#{file_count}_#{last_role}.css" File.open(cache_filename, 'w') {|f| f.write(css_to_cache)} css_to_cache = "" end self.cached = true save! end def clear_cache! skin_dir = Skin.skins_dir + skin_dirname FileUtils.rm_rf skin_dir # clear out old if exists self.cached = false save! end def recache_children! child_ids = SkinParent.get_all_child_ids(id) Skin.where(cached: true, id: child_ids).find_each(&:cache!) end def get_sheet_role "#{get_role}_#{get_media_for_filename}_#{ie_condition}" end # have to handle any media types that aren't a single alphanumeric word here def get_media_for_filename ((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).map {|m| case when m.match(/max-width: 42em/) "narrow" when m.match(/max-width: 62em/) "midsize" when m.match(/prefers-color-scheme: dark/) "dark" when m.match(/prefers-color-scheme: light/) "light" else m end }.join(".") end def parse_media_from_filename(media_string) media_string.gsub(/narrow/, "only screen and (max-width: 42em)") .gsub(/midsize/, "only screen and (max-width: 62em)") .gsub(/dark/, "(prefers-color-scheme: dark)") .gsub(/light/, "(prefers-color-scheme: light)") .gsub(".", ", ") end def parse_sheet_role(role_string) (sheet_role, sheet_media, sheet_ie_condition) = role_string.split('_') sheet_media = parse_media_from_filename(sheet_media) [sheet_role, sheet_media, sheet_ie_condition] end def get_css if filename File.read(Rails.public_path.join(filename)) else css end end def get_media(separator=", ") ((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).join(separator) end def get_role self.role || DEFAULT_ROLE end def get_all_parents all_parents = [] parent_skins.each do |parent| all_parents += parent.get_all_parents all_parents << parent end all_parents end # This is the main function that actually returns code to be embedded in a page def get_style(roles_to_include = DEFAULT_ROLES_TO_INCLUDE) style = "" if self.get_role != "override" && self.get_role != "site" && self.id != AdminSetting.default_skin_id && AdminSetting.default_skin.is_a?(Skin) style += AdminSetting.default_skin.get_style(roles_to_include) end style += self.get_style_block(roles_to_include) style.html_safe end def get_ie_comment(style, ie_condition = self.ie_condition) if ie_condition.present? ie_comment= "" else style end end # This builds the stylesheet, so the order is important def get_wizard_settings style = "" style += font_size_styles(base_em) if base_em.present? style += font_styles(font) if font.present? style += background_color_styles(background_color) if background_color.present? style += paragraph_margin_styles(paragraph_margin) if paragraph_margin.present? style += foreground_color_styles(foreground_color) if foreground_color.present? style += header_styles(headercolor) if headercolor.present? style += accent_color_styles(accent_color) if accent_color.present? style += work_margin_styles(margin) if margin.present? style end def get_style_block(roles_to_include) block = "" if self.cached? # cached skin in a directory block = get_cached_style(roles_to_include) else # recursively get parents parent_skins.each do |parent| block += parent.get_style_block(roles_to_include) + "\n" end # finally get this skin if roles_to_include.include?(get_role) if self.filename.present? block += get_ie_comment(stylesheet_link(self.filename, get_media)) else if (wizard_block = get_wizard_settings).present? block += '' end if self.css.present? block += get_ie_comment('') end end end end return block end def get_cached_style(roles_to_include) block = "" self_skin_dir = Skin.skins_dir + self.skin_dirname Skin.skin_dir_entries(self_skin_dir, /^\d+_(.*)\.css$/).each do |sub_file| if sub_file.match(/^\d+_(.*)\.css$/) (sheet_role, sheet_media, sheet_ie_condition) = parse_sheet_role($1) if roles_to_include.include?(sheet_role) block += get_ie_comment(stylesheet_link(SKIN_PATH + self.skin_dirname + sub_file, sheet_media), sheet_ie_condition) + "\n" end end end block end def stylesheet_link(file, media) # we want one and only one / in the url path '' end def self.naturalized(string) string.scan(/[^\d]+|[\d]+/).collect { |f| f.match(/\d+(\.\d+)?/) ? f.to_f : f } end def self.load_site_css Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).each do |version| version_dir = "#{Skin.site_skins_dir + version}/" if File.directory?(version_dir) # let's load up the file skins = [] Skin.skin_dir_entries(version_dir, /^(\d+)-(.*)\.css/).each do |skin_file| filename = SITE_SKIN_PATH + version + '/' + skin_file skin_file.match(/^(\d+)-(.*)\.css/) position = $1.to_i title = $2 title.gsub!(/(\-|\_)/, ' ') description = "Version #{version} of the #{title} component (#{position}) of the default archive site design." firstline = File.open(version_dir + skin_file, &:readline) skin_role = "site" if firstline.match(/ROLE: (\w+)/) skin_role = $1 end skin_media = ["screen"] if firstline.match(/MEDIA: (.*?) ENDMEDIA/) skin_media = $1.split(/,\s?/) elsif firstline.match(/MEDIA: (\w+)/) skin_media = [$1] end skin_ie = "" if firstline.match(/IE_CONDITION: (\w+)/) skin_ie = $1 end full_title = "Archive #{version}: (#{position}) #{title}" skin = Skin.find_by(title: full_title) if skin.nil? skin = Skin.new end # update the attributes skin.title ||= full_title skin.filename = filename skin.description = description skin.public = true skin.media = skin_media skin.role = skin_role skin.ie_condition = skin_ie skin.unusable = true skin.official = true skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png") skin.save!(validate: false) skins << skin end # set up the parent relationship of all the skins in this version top_skin = Skin.find_by(title: "Archive #{version}") if top_skin top_skin.clear_cache! if top_skin.cached? top_skin.skin_parents.delete_all else top_skin = Skin.new(title: "Archive #{version}", css: "", description: "Version #{version} of the default Archive style.", public: true, role: "site", media: ["screen"]) end top_skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png") top_skin.official = true top_skin.save!(validate: false) skins.each_with_index do |skin, index| skin_parent = top_skin.skin_parents.build(child_skin: top_skin, parent_skin: skin, position: index+1) skin_parent.save! end if %w(staging unproduction).include? Rails.env top_skin.cache! end end end end # get the directory name for the skin file def skin_dirname "skin_#{self.id}_#{self.title.gsub(/[^\w]/, '_')}/".downcase end def self.skins_dir Rails.public_path.join(SKIN_PATH).to_s end def self.skin_dir_entries(dir, regex) Dir.entries(dir).select {|f| f.match(regex)}.sort_by {|f| Skin.naturalized(f.to_s)} end def self.site_skins_dir Rails.public_path.join(SITE_SKIN_PATH).to_s end # Get the most recent version and find the topmost skin def self.get_current_version Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).last end def self.get_current_site_skin current_version = Skin.get_current_version if current_version Skin.find_by(title: "Archive #{Skin.get_current_version}", official: true) else nil end end def self.default Skin.find_by(title: "Default", official: true) || Skin.create_default end def self.create_default transaction do skin = Skin.find_or_initialize_by(title: "Default") skin.official = true skin.public = true skin.role = "site" skin.css = "" skin.set_thumbnail_from_current_version skin.save! skin end end def self.set_default_to_current_version transaction do default_skin = default default_skin.set_thumbnail_from_current_version parent_skin = get_current_site_skin if parent_skin && default_skin.parent_skins != [parent_skin] default_skin.skin_parents.destroy_all default_skin.skin_parents.build(parent_skin: parent_skin, position: 1) end default_skin.save! end end def set_thumbnail_from_current_version current_version = self.class.get_current_version icon_path = if current_version self.class.site_skins_dir + current_version + "/preview.png" else self.class.site_skins_dir + "preview.png" end self.icon.attach(io: File.open(icon_path), content_type: "image/png", filename: "preview.png") end end