From bfad9c4e9dba0e5c82099d205410faa9bf810410 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sun, 7 Jun 2026 17:02:53 +1000 Subject: [PATCH] Generated monogram fallback + optional dark-mode icon per application 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 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) --- .../admin/applications_controller.rb | 2 +- app/helpers/application_helper.rb | 41 +++++++++ app/models/application.rb | 91 +++++++++++-------- app/views/admin/applications/_form.html.erb | 17 ++++ app/views/admin/applications/index.html.erb | 8 +- app/views/admin/applications/show.html.erb | 8 +- app/views/dashboard/index.html.erb | 8 +- app/views/oidc/consent.html.erb | 10 +- app/views/shared/_app_monogram.html.erb | 18 ++++ config/initializers/version.rb | 2 +- test/helpers/application_helper_test.rb | 34 +++++++ test/models/application_test.rb | 27 ++++++ 12 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 app/views/shared/_app_monogram.html.erb create mode 100644 test/helpers/application_helper_test.rb diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 885e89a..dc7e8ce 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -110,7 +110,7 @@ module Admin permitted = params.require(:application).permit( :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, - :icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent + :icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent ) # Handle headers_config - it comes as a JSON string from the text area diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4263552..ca1f6ea 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -44,4 +44,45 @@ module ApplicationHelper else "border-gray-200 dark:border-gray-700" end end + + # Picks 1-2 character initials for a monogram fallback when an Application + # has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the + # first two letters of the name (Audiobookshelf -> AU). + MONOGRAM_PALETTE = %w[ + #4f46e5 #0891b2 #16a34a #ca8a04 + #db2777 #9333ea #ea580c #475569 + ].freeze + + def monogram_initials(name) + return "?" if name.blank? + caps = name.scan(/[A-Z]/) + initials = if caps.size >= 2 + caps.first(2).join + else + name.upcase.gsub(/[^A-Z0-9]/, "").first(2) + end + initials.presence || "?" + end + + def monogram_color(name) + return MONOGRAM_PALETTE.first if name.blank? + index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size + MONOGRAM_PALETTE[index] + end + + # Renders an application icon as a that swaps based on the user's + # color-scheme preference. If only `icon` is attached, the same image is used + # in both modes. Caller is responsible for ensuring at least app.icon is + # attached; the monogram fallback handles the no-icon case separately. + def app_icon_picture(app, class:, alt: nil) + img_class = binding.local_variable_get(:class) + alt ||= "#{app.name} icon" + light = url_for(app.icon) + dark = app.icon_dark.attached? ? url_for(app.icon_dark) : nil + tag.picture do + sources = [] + sources << tag.source(media: "(prefers-color-scheme: dark)", srcset: dark) if dark + safe_join(sources + [image_tag(app.icon, class: img_class, alt: alt)]) + end + end end diff --git a/app/models/application.rb b/app/models/application.rb index 84c44ed..7c32717 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -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 diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index d79accf..51b5733 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -115,6 +115,23 @@ + +
+ <%= form.label :icon_dark, "Dark mode icon (optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> +

Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.

+ <% if application.icon_dark.attached? && application.persisted? && application.icon_dark.blob&.persisted? && application.icon_dark.blob.key.present? %> +
+ <%= image_tag application.icon_dark, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 bg-gray-900", alt: "Current dark-mode icon" %> +
+

Current dark-mode icon

+

<%= number_to_human_size(application.icon_dark.blob.byte_size) %>

+
+
+ <% end %> + <%= form.file_field :icon_dark, + accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml", + class: "mt-2 block w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 dark:file:bg-blue-900/30 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50" %> +
diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index cbb62b2..0ee136e 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -30,13 +30,9 @@
<% if application.icon.attached? %> - <%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0", alt: "#{application.name} icon" %> + <%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %> <% else %> -
- - - -
+ <%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %> <% end %> <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb index 103a793..16d40f6 100644 --- a/app/views/admin/applications/show.html.erb +++ b/app/views/admin/applications/show.html.erb @@ -49,13 +49,9 @@
<% if @application.icon.attached? %> - <%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{@application.name} icon" %> + <%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %> <% else %> -
- - - -
+ <%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %> <% end %>

<%= @application.name %>

diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index c509ab6..ffbdda0 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -130,13 +130,9 @@
<% if app.icon.attached? %> - <%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{app.name} icon" %> + <%= app_icon_picture app, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %> <% else %> -
- - - -
+ <%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %> <% end %>
diff --git a/app/views/oidc/consent.html.erb b/app/views/oidc/consent.html.erb index 97404e3..4964999 100644 --- a/app/views/oidc/consent.html.erb +++ b/app/views/oidc/consent.html.erb @@ -2,12 +2,12 @@
<% if @application.icon.attached? %> - <%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm mb-4", alt: "#{@application.name} icon" %> +
+ <%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %> +
<% else %> -
- - - +
+ <%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
<% end %>

Authorize Application

diff --git a/app/views/shared/_app_monogram.html.erb b/app/views/shared/_app_monogram.html.erb new file mode 100644 index 0000000..04527c6 --- /dev/null +++ b/app/views/shared/_app_monogram.html.erb @@ -0,0 +1,18 @@ +<%# Renders a deterministic monogram SVG for an Application that has no icon. + Locals: + name - the application name (required) + class - css classes for the element (e.g. "h-12 w-12 rounded-lg") +%> +<% initials = monogram_initials(name) %> +" + role="img" aria-label="<%= name %> icon"> + + + <%= initials %> + + diff --git a/config/initializers/version.rb b/config/initializers/version.rb index da9ee54..4d2b8fe 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Clinch - VERSION = "0.13.1" + VERSION = "0.14.0" end diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb new file mode 100644 index 0000000..3834ff9 --- /dev/null +++ b/test/helpers/application_helper_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class ApplicationHelperTest < ActionView::TestCase + test "monogram_initials picks capitals from camelCase" do + assert_equal "SL", monogram_initials("ShelfLife") + assert_equal "KR", monogram_initials("KavitaReader") + assert_equal "AB", monogram_initials("AudioBookShelf") # first two of 4 capitals + end + + test "monogram_initials falls back to first two letters when fewer than two capitals" do + assert_equal "AU", monogram_initials("Audiobookshelf") + assert_equal "ME", monogram_initials("metube") + assert_equal "GI", monogram_initials("git") + end + + test "monogram_initials handles single-character and unusual names" do + assert_equal "X", monogram_initials("X") + assert_equal "X1", monogram_initials("X1") + assert_equal "?", monogram_initials("") + assert_equal "?", monogram_initials(nil) + end + + test "monogram_color is deterministic for the same name" do + a = monogram_color("ShelfLife") + b = monogram_color("ShelfLife") + assert_equal a, b + assert_match(/\A#[0-9a-f]{6}\z/i, a) + end + + test "monogram_color differs for different names" do + # not a guarantee for all pairs, but should hold for at least one pair + assert_not_equal monogram_color("Kavita"), monogram_color("Navidrome") + end +end diff --git a/test/models/application_test.rb b/test/models/application_test.rb index 063b623..052725b 100644 --- a/test/models/application_test.rb +++ b/test/models/application_test.rb @@ -29,4 +29,31 @@ class ApplicationTest < ActiveSupport::TestCase tempfile&.close tempfile&.unlink end + + test "icon_dark is independently attachable and SVG-sanitized" do + app = applications(:kavita_app) + + svg = %() + tempfile = Tempfile.new(["dark", ".svg"]).tap do |t| + t.write(svg) + t.rewind + end + uploaded = ActionDispatch::Http::UploadedFile.new( + tempfile: tempfile, + filename: "dark.svg", + type: "image/svg+xml" + ) + + assert_nothing_raised do + app.update!(icon_dark: uploaded) + end + + assert app.icon_dark.attached? + cleaned = app.icon_dark.download + refute_match(/