Generated monogram fallback + optional dark-mode icon per application
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

When an application has no icon attached, render a deterministic
monogram SVG instead of the generic picture-frame placeholder. Initials
are picked from capital letters in the name (ShelfLife -> SL); fall
back to the first two letters when fewer than two capitals exist
(Audiobookshelf -> AU). Background colour is hashed from the name for
stable per-app identity across visits.

Adds an optional second icon attachment, icon_dark, alongside the main
icon. When present, render a <picture> with a prefers-color-scheme:
dark source so the browser swaps automatically; when absent, the main
icon is used in both modes. The SVG sanitization, content-type fix,
and size/format validation now run over both attachments uniformly.

Bumps to 0.14.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-07 17:02:53 +10:00
parent 5b41db2c6a
commit bfad9c4e9d
12 changed files with 201 additions and 65 deletions

View File

@@ -25,9 +25,12 @@ class Application < ApplicationRecord
after_commit :bust_forward_auth_cache, if: :forward_auth?
has_one_attached :icon
has_one_attached :icon_dark
before_validation :sanitize_svg_icon, if: -> { attachment_changes["icon"].present? }
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
before_validation :sanitize_svg_icons
after_save :fix_icon_content_types
has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group
@@ -55,7 +58,7 @@ class Application < ApplicationRecord
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
# Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? }
validate :icon_validation
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
@@ -268,43 +271,49 @@ class Application < ApplicationRecord
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
def fix_icon_content_type
return unless icon.attached?
# Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml")
def fix_icon_content_types
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
# Fix SVG content type if it was detected incorrectly
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
attachment.blob.update(content_type: "image/svg+xml")
end
end
end
def sanitize_svg_icon
def sanitize_svg_icons
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
# this point (Active Storage uploads in before_save), so we cannot call
# icon.download — we must read from the pending attachable.
# download — we must read from the pending attachable.
#
# icon.attach below re-sets attachment_changes and would re-fire this
# callback; we skip if the pending attachable is the cleaned hash we just
# installed (tracked by object identity).
change = attachment_changes["icon"]
return unless change
attachable = change.attachable
return if attachable.equal?(@svg_sanitized_attachable)
# attach below re-sets attachment_changes and would re-fire this callback;
# we skip if the pending attachable is the cleaned hash we just installed
# (tracked by object identity, per-attribute).
@svg_sanitized_attachables ||= {}
raw_svg, filename, content_type = read_pending_icon(attachable)
return unless raw_svg
return unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
ICON_ATTACHMENTS.each do |attr|
change = attachment_changes[attr.to_s]
next unless change
attachable = change.attachable
next if attachable.equal?(@svg_sanitized_attachables[attr])
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
raw_svg, filename, content_type = read_pending_icon(attachable)
next unless raw_svg
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
sanitized = {
io: StringIO.new(clean_svg),
filename: filename,
content_type: "image/svg+xml"
}
@svg_sanitized_attachable = sanitized
icon.attach(sanitized)
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
sanitized = {
io: StringIO.new(clean_svg),
filename: filename,
content_type: "image/svg+xml"
}
@svg_sanitized_attachables[attr] = sanitized
public_send(attr).attach(sanitized)
end
end
def read_pending_icon(attachable)
@@ -327,17 +336,19 @@ class Application < ApplicationRecord
end
def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type)
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, "must be less than 2MB")
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
unless allowed_types.include?(attachment.content_type)
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
end
if attachment.blob.byte_size > 2.megabytes
errors.add(attr, "must be less than 2MB")
end
end
end