otwcode/otwarchive

View on GitHub
app/models/skin.rb

Summary

Maintainability
F
3 days
Test Coverage
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, Array

  # 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_attached_file :icon,
                    styles: { standard: "100x100>" },
                    url: "/system/:class/:attachment/:id/:style/:basename.:extension",
                    path: %w(staging production).include?(Rails.env) ? ":class/:attachment/:id/:style.:extension" : ":rails_root/public:url",
                    storage: %w(staging production).include?(Rails.env) ? :s3 : :filesystem,
                    s3_protocol: "https",
                    default_url: "/images/skins/iconsets/default/icon_skins.png"

  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_attachment_content_type :icon, content_type: /image\/\S+/, allow_nil: true
  validates_attachment_size :icon, less_than: 500.kilobytes, allow_nil: true
  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, ts("We don't currently support the media type %{media}, sorry! If we should, please let Support know.", 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_file_name)
    errors.add(:base, ts("You need to upload a screencap if you want to share your skin."))
  end

  validates_presence_of :title
  validates :title, uniqueness: { message: ts("must be unique"), case_sensitive: true }

  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= "<!--[if "
      ie_comment += "lte " if ie_condition.match(/or_lower/)
      ie_comment += "gte " if ie_condition.match(/or_higher/)
      ie_comment += "IE"
      ie_comment += " #{$1}" if ie_condition.match(/IE(\d)/)
      ie_comment += "]>" + style + "<![endif]-->"
    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 += '<style type="text/css" media="' + get_media + '">' + wizard_block + '</style>'
          end
          if self.css.present?
            block += get_ie_comment('<style type="text/css" media="' + get_media + '">' + self.css + '</style>')
          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
    '<link rel="stylesheet" type="text/css" media="' + media + '" href="/' + file.gsub(/^\/*/,"") + '" />'
  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
          File.open(version_dir + 'preview.png', 'rb') {|preview_file| skin.icon = preview_file}
          skin.save!
          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
        File.open(version_dir + 'preview.png', 'rb') {|preview_file| top_skin.icon = preview_file}
        top_skin.official = true
        top_skin.save!
        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 production).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

    File.open(icon_path) do |icon_file|
      self.icon = icon_file
    end
  end
end