Generated monogram fallback + optional dark-mode icon per application
Some checks failed
Some checks failed
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:
@@ -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
|
||||
|
||||
@@ -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 <picture> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -115,6 +115,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= form.label :icon_dark, "Dark mode icon (optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.</p>
|
||||
<% if application.icon_dark.attached? && application.persisted? && application.icon_dark.blob&.persisted? && application.icon_dark.blob.key.present? %>
|
||||
<div class="mt-2 mb-3 flex items-center gap-4">
|
||||
<%= 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" %>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-medium">Current dark-mode icon</p>
|
||||
<p class="text-xs"><%= number_to_human_size(application.icon_dark.blob.byte_size) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% 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" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -30,13 +30,9 @@
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% 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 %>
|
||||
<div class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
|
||||
@@ -49,13 +49,9 @@
|
||||
<div class="sm:flex sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<% 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 %>
|
||||
<div class="h-16 w-16 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center shrink-0">
|
||||
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
|
||||
<% end %>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
|
||||
|
||||
@@ -130,13 +130,9 @@
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<% 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 %>
|
||||
<div class="h-12 w-12 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %>
|
||||
<% end %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
|
||||
<div class="mb-8 text-center">
|
||||
<% 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" %>
|
||||
<div class="mx-auto h-20 w-20 mb-4">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center mb-4">
|
||||
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div class="mx-auto mb-4">
|
||||
<%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>
|
||||
|
||||
18
app/views/shared/_app_monogram.html.erb
Normal file
18
app/views/shared/_app_monogram.html.erb
Normal file
@@ -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 <svg> element (e.g. "h-12 w-12 rounded-lg")
|
||||
%>
|
||||
<% initials = monogram_initials(name) %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"
|
||||
class="<%= local_assigns[:class] || "h-12 w-12 rounded-lg" %>"
|
||||
role="img" aria-label="<%= name %> icon">
|
||||
<rect width="40" height="40" fill="<%= monogram_color(name) %>" />
|
||||
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle"
|
||||
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-weight="600" fill="#ffffff"
|
||||
font-size="<%= initials.length == 1 ? 22 : 17 %>"
|
||||
letter-spacing="-0.5">
|
||||
<%= initials %>
|
||||
</text>
|
||||
</svg>
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.13.1"
|
||||
VERSION = "0.14.0"
|
||||
end
|
||||
|
||||
34
test/helpers/application_helper_test.rb
Normal file
34
test/helpers/application_helper_test.rb
Normal file
@@ -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
|
||||
@@ -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 = %(<svg xmlns="http://www.w3.org/2000/svg"><script>boom()</script><circle cx="5" cy="5" r="3"/></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(/<script/i, cleaned)
|
||||
assert_match(/<circle/, cleaned)
|
||||
ensure
|
||||
tempfile&.close
|
||||
tempfile&.unlink
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user