FEATURE: Allow themes to define screenshots (#29079)

This commit allows themes to define up to 2 screenshots
in about.json. These should be paths within the theme's
git repository, images with a 1MB max file size and max width 3840x2160.

These screenshots will be downloaded and stored against a theme
field, and we will use these in the redesigned theme grid UI.

These screenshots will be updated when the theme is updated
in the same way the additional theme files are.

For now this is gated behind a hidden `theme_download_screenshots`
site setting, to allow us to test this on a small number of sites without
making other sites make unnecessary uploads.

**Future considerations:**

* We may want to have a specialized naming system for screenshots. E.g. having light.png/dark.png/some_palette.png
* We may want to show more than one screenshot for the theme, maybe in a carousel or reacting to dark mode or color palette changes
* We may want to allow clicking on the theme screenshot to show a lightbox
* We may want to make an optimized thumbnail image for the theme grid

---------

Co-authored-by: Ted Johansson <ted@discourse.org>
This commit is contained in:
Martin Brennan 2024-10-28 10:10:20 +10:00 committed by GitHub
parent 77f63a45d3
commit 456fbb1dbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 247 additions and 32 deletions

View File

@ -82,10 +82,10 @@ export default class ThemeCard extends Component {
> >
<:content> <:content>
<div class="theme-card__image-wrapper"> <div class="theme-card__image-wrapper">
{{#if @theme.screenshot}} {{#if @theme.screenshot_url}}
<img <img
class="theme-card__image" class="theme-card__image"
src={{htmlSafe @theme.screenshot}} src={{@theme.screenshot_url}}
alt={{@theme.name}} alt={{@theme.name}}
/> />
{{else}} {{else}}

View File

@ -31,6 +31,9 @@ class RemoteTheme < ActiveRecord::Base
MAX_ASSET_FILE_SIZE = 8.megabytes MAX_ASSET_FILE_SIZE = 8.megabytes
MAX_THEME_FILE_COUNT = 1024 MAX_THEME_FILE_COUNT = 1024
MAX_THEME_SIZE = 256.megabytes MAX_THEME_SIZE = 256.megabytes
MAX_THEME_SCREENSHOT_FILE_SIZE = 1.megabyte
MAX_THEME_SCREENSHOT_DIMENSIONS = [3840, 2160] # 4K resolution
THEME_SCREENSHOT_ALLOWED_FILE_TYPES = %w[.jpg .jpeg .gif .png].freeze
has_one :theme, autosave: false has_one :theme, autosave: false
scope :joined_remotes, scope :joined_remotes,
@ -250,15 +253,7 @@ class RemoteTheme < ActiveRecord::Base
theme_info["assets"]&.each do |name, relative_path| theme_info["assets"]&.each do |name, relative_path|
if path = importer.real_path(relative_path) if path = importer.real_path(relative_path)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}" upload = create_upload(path, relative_path)
File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily
upload =
UploadCreator.new(
File.open(new_path),
File.basename(relative_path),
for_theme: true,
).create_for(theme.user_id)
if !upload.errors.empty? if !upload.errors.empty?
raise ImportError, raise ImportError,
I18n.t( I18n.t(
@ -277,6 +272,67 @@ class RemoteTheme < ActiveRecord::Base
end end
end end
# TODO (martin): Until we are ready to roll this out more
# widely, let's avoid doing this work for most sites.
if SiteSetting.theme_download_screenshots
theme_info["screenshots"] = Array.wrap(theme_info["screenshots"]).take(2)
theme_info["screenshots"].each_with_index do |relative_path, idx|
if path = importer.real_path(relative_path)
if !THEME_SCREENSHOT_ALLOWED_FILE_TYPES.include?(File.extname(path))
raise ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_type",
file_name: File.basename(path),
accepted_formats: THEME_SCREENSHOT_ALLOWED_FILE_TYPES.join(","),
)
end
if File.size(path) > MAX_THEME_SCREENSHOT_FILE_SIZE
raise ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_size",
file_name: File.basename(path),
max_size:
ActiveSupport::NumberHelper.number_to_human_size(
MAX_THEME_SCREENSHOT_FILE_SIZE,
),
)
end
screenshot_width, screenshot_height = FastImage.size(path)
if (screenshot_width.nil? || screenshot_height.nil?) ||
screenshot_width > MAX_THEME_SCREENSHOT_DIMENSIONS[0] ||
screenshot_height > MAX_THEME_SCREENSHOT_DIMENSIONS[1]
raise ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_dimensions",
file_name: File.basename(path),
width: screenshot_width.to_i,
height: screenshot_height.to_i,
max_width: MAX_THEME_SCREENSHOT_DIMENSIONS[0],
max_height: MAX_THEME_SCREENSHOT_DIMENSIONS[1],
)
end
upload = create_upload(path, relative_path)
if !upload.errors.empty?
raise ImportError,
I18n.t(
"themes.import_error.screenshot",
errors: upload.errors.full_messages.join(","),
)
end
updated_fields << theme.set_field(
target: :common,
name: "screenshot_#{idx + 1}",
type: :theme_screenshot_upload_var,
upload_id: upload.id,
)
end
end
end
# Update all theme attributes if this is just a placeholder # Update all theme attributes if this is just a placeholder
if self.remote_url.present? && !self.local_version && !self.commits_behind if self.remote_url.present? && !self.local_version && !self.commits_behind
self.theme.name = theme_info["name"] self.theme.name = theme_info["name"]
@ -466,6 +522,19 @@ class RemoteTheme < ActiveRecord::Base
def is_git? def is_git?
remote_url.present? remote_url.present?
end end
def create_upload(path, relative_path)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}"
# OptimizedImage has strict file name restrictions, so rename temporarily
File.rename(path, new_path)
UploadCreator.new(
File.open(new_path),
File.basename(relative_path),
for_theme: true,
).create_for(theme.user_id)
end
end end
# == Schema Information # == Schema Information

View File

@ -16,11 +16,31 @@ class ThemeField < ActiveRecord::Base
validate :migration_filename_is_valid, if: :migration_field? validate :migration_filename_is_valid, if: :migration_field?
after_save do after_save do
if self.type_id == ThemeField.types[:theme_upload_var] && saved_change_to_upload_id? if (
self.type_id == ThemeField.types[:theme_screenshot_upload_var] ||
self.type_id == ThemeField.types[:theme_upload_var]
) && saved_change_to_upload_id?
UploadReference.ensure_exist!(upload_ids: [self.upload_id], target: self) UploadReference.ensure_exist!(upload_ids: [self.upload_id], target: self)
end end
end end
after_save do
dependent_fields.each(&:invalidate_baked!)
if upload && svg_sprite_field?
upsert_svg_sprite!
SvgSprite.expire_cache
end
end
after_destroy do
if svg_sprite_field?
ThemeSvgSprite.where(theme_id: theme_id).delete_all
SvgSprite.expire_cache
end
end
scope :find_by_theme_ids, scope :find_by_theme_ids,
->(theme_ids) do ->(theme_ids) do
return none if theme_ids.blank? return none if theme_ids.blank?
@ -69,6 +89,7 @@ class ThemeField < ActiveRecord::Base
theme_var: 4, # No longer used theme_var: 4, # No longer used
yaml: 5, yaml: 5,
js: 6, js: 6,
theme_screenshot_upload_var: 7,
) )
end end
@ -717,21 +738,8 @@ class ThemeField < ActiveRecord::Base
end end
end end
after_save do def upload_url
dependent_fields.each(&:invalidate_baked!) self.upload&.url
if upload && svg_sprite_field?
upsert_svg_sprite!
SvgSprite.expire_cache
end
end
after_destroy do
if svg_sprite_field?
ThemeSvgSprite.where(theme_id: theme_id).delete_all
SvgSprite.expire_cache
end
end end
private private

View File

@ -13,7 +13,8 @@ class ThemeSerializer < BasicThemeSerializer
:description, :description,
:enabled?, :enabled?,
:disabled_at, :disabled_at,
:theme_fields :theme_fields,
:screenshot_url
has_one :color_scheme, serializer: ColorSchemeSerializer, embed: :object has_one :color_scheme, serializer: ColorSchemeSerializer, embed: :object
has_one :user, serializer: UserNameSerializer, embed: :object has_one :user, serializer: UserNameSerializer, embed: :object
@ -47,6 +48,13 @@ class ThemeSerializer < BasicThemeSerializer
@include_theme_field_values || object.remote_theme_id.nil? @include_theme_field_values || object.remote_theme_id.nil?
end end
def screenshot_url
object
.theme_fields
.find { |field| field.type_id == ThemeField.types[:theme_screenshot_upload_var] }
&.upload_url
end
def child_themes def child_themes
object.child_themes object.child_themes
end end

View File

@ -85,6 +85,10 @@ en:
import_error: import_error:
generic: An error occurred while importing that theme generic: An error occurred while importing that theme
upload: "Error creating upload asset: %{name}. %{errors}" upload: "Error creating upload asset: %{name}. %{errors}"
screenshot: "Error importing theme screenshots. %{errors}"
screenshot_invalid_type: "The theme screenshots must be in one of the following formats: %{accepted_formats}. The screenshot %{file_name} has an invalid format."
screenshot_invalid_size: "The theme screenshots must be less than %{max_size}. The screenshot %{file_name} is too large."
screenshot_invalid_dimensions: "The theme screenshots must be maximum %{max_width}x%{max_height}. The screenshot %{file_name} exceeds this. Its dimensions are %{width}x%{height}."
about_json: "Import Error: about.json does not exist, or is invalid. Are you sure this is a Discourse Theme?" about_json: "Import Error: about.json does not exist, or is invalid. Are you sure this is a Discourse Theme?"
about_json_too_big: "Import Error: about.json is bigger than the %{limit} limit." about_json_too_big: "Import Error: about.json is bigger than the %{limit} limit."
about_json_values: "about.json contains invalid values: %{errors}" about_json_values: "about.json contains invalid values: %{errors}"

View File

@ -1525,6 +1525,9 @@ files:
default: "wasm|jpg|jpeg|png|woff|woff2|svg|eot|ttf|otf|gif|webp|avif|js" default: "wasm|jpg|jpeg|png|woff|woff2|svg|eot|ttf|otf|gif|webp|avif|js"
type: list type: list
list_type: file_types list_type: file_types
theme_download_screenshots:
default: false
hidden: true
authorized_extensions: authorized_extensions:
client: true client: true
default: "jpg|jpeg|png|gif|heic|heif|webp|avif" default: "jpg|jpeg|png|gif|heic|heif|webp|avif"

View File

@ -34,7 +34,8 @@ RSpec.describe RemoteTheme do
"type": "setting", "type": "setting",
"value": "list_setting" "value": "list_setting"
} }
} },
"screenshots": ["screenshots/1.jpeg", "screenshots/2.jpeg"]
} }
JSON JSON
end end
@ -72,6 +73,8 @@ RSpec.describe RemoteTheme do
"settings.yaml" => settings, "settings.yaml" => settings,
"locales/en.yml" => "sometranslations", "locales/en.yml" => "sometranslations",
"migrations/settings/0001-some-migration.js" => migration_js, "migrations/settings/0001-some-migration.js" => migration_js,
"screenshots/1.jpeg" => file_from_fixtures("logo.jpg", "images"),
"screenshots/2.jpeg" => file_from_fixtures("logo.jpg", "images"),
) )
end end
@ -355,7 +358,7 @@ RSpec.describe RemoteTheme do
stub_const(RemoteTheme, "MAX_THEME_FILE_COUNT", 1) do stub_const(RemoteTheme, "MAX_THEME_FILE_COUNT", 1) do
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error( expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
RemoteTheme::ImportError, RemoteTheme::ImportError,
I18n.t("themes.import_error.too_many_files", count: 15, limit: 1), I18n.t("themes.import_error.too_many_files", count: 17, limit: 1),
) )
end end
end end
@ -384,6 +387,85 @@ RSpec.describe RemoteTheme do
) )
end end
end end
describe "screenshots" do
before { SiteSetting.theme_download_screenshots = true }
it "fails if any of the provided screenshots is not an accepted file type" do
stub_const(RemoteTheme, "THEME_SCREENSHOT_ALLOWED_FILE_TYPES", [".bmp"]) do
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
RemoteTheme::ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_type",
file_name: "1.jpeg",
accepted_formats: ".bmp",
),
)
end
end
it "fails if any of the provided screenshots is too big" do
stub_const(RemoteTheme, "MAX_THEME_SCREENSHOT_FILE_SIZE", 1.byte) do
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
RemoteTheme::ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_size",
file_name: "1.jpeg",
max_size: "1 Bytes",
),
)
end
end
it "fails if any of the provided screenshots has dimensions that are too big" do
FastImage
.expects(:size)
.with { |arg| arg.match(%r{/screenshots/1\.jpeg}) }
.returns([512, 512])
stub_const(RemoteTheme, "MAX_THEME_SCREENSHOT_DIMENSIONS", [1, 1]) do
expect { RemoteTheme.import_theme(initial_repo_url) }.to raise_error(
RemoteTheme::ImportError,
I18n.t(
"themes.import_error.screenshot_invalid_dimensions",
file_name: "1.jpeg",
width: 512,
height: 512,
max_width: 1,
max_height: 1,
),
)
end
end
it "creates uploads and associated theme fields for all theme screenshots" do
FastImage
.stubs(:size)
.with { |arg| arg.match(%r{/screenshots/1\.jpeg}) }
.returns([800, 600])
FastImage
.stubs(:size)
.with { |arg| arg.match(%r{/screenshots/2\.jpeg}) }
.returns([1024, 768])
theme = RemoteTheme.import_theme(initial_repo_url)
screenshot_1 = theme.theme_fields.find_by(name: "screenshot_1")
screenshot_2 = theme.theme_fields.find_by(name: "screenshot_2")
expect(screenshot_1).to be_present
expect(screenshot_1.type_id).to eq(ThemeField.types[:theme_screenshot_upload_var])
expect(screenshot_2).to be_present
expect(screenshot_2.type_id).to eq(ThemeField.types[:theme_screenshot_upload_var])
expect(screenshot_1.upload).to be_present
expect(screenshot_2.upload).to be_present
expect(UploadReference.exists?(target: screenshot_1)).to eq(true)
expect(UploadReference.exists?(target: screenshot_2)).to eq(true)
expect(screenshot_1.upload.original_filename).to eq("1.jpeg")
expect(screenshot_2.upload.original_filename).to eq("2.jpeg")
end
end
end end
let(:github_repo) do let(:github_repo) do

View File

@ -2,8 +2,9 @@
RSpec.describe ThemeSerializer do RSpec.describe ThemeSerializer do
describe "load theme settings" do describe "load theme settings" do
fab!(:theme)
it "should add error message when settings format is invalid" do it "should add error message when settings format is invalid" do
theme = Fabricate(:theme)
Theme Theme
.any_instance .any_instance
.stubs(:settings) .stubs(:settings)
@ -16,11 +17,51 @@ RSpec.describe ThemeSerializer do
it "should add errors messages from theme fields" do it "should add errors messages from theme fields" do
error = "error when compiling theme field" error = "error when compiling theme field"
theme = Fabricate(:theme)
theme_field = Fabricate(:theme_field, error: error, theme: theme) theme_field = Fabricate(:theme_field, error: error, theme: theme)
serialized = ThemeSerializer.new(theme.reload).as_json[:theme] serialized = ThemeSerializer.new(theme.reload).as_json[:theme]
expect(serialized[:errors].count).to eq(1) expect(serialized[:errors].count).to eq(1)
expect(serialized[:errors][0]).to eq(error) expect(serialized[:errors][0]).to eq(error)
end end
end end
describe "screenshot_url" do
fab!(:theme)
let(:serialized) { ThemeSerializer.new(theme.reload).as_json[:theme] }
it "should include screenshot_url when there is a theme field with screenshot upload type" do
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:theme_screenshot_upload_var],
name: "theme_screenshot_1",
upload: Fabricate(:upload),
)
expect(serialized[:screenshot_url]).to be_present
end
it "should not include screenshot_url when there is no theme field with screenshot upload type" do
expect(serialized[:screenshot_url]).to be_nil
end
it "should handle multiple screenshot fields and use the first one" do
first_upload = Fabricate(:upload)
second_upload = Fabricate(:upload)
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:theme_screenshot_upload_var],
name: "theme_screenshot_1",
upload: first_upload,
)
Fabricate(
:theme_field,
theme: theme,
type_id: ThemeField.types[:theme_screenshot_upload_var],
name: "theme_screenshot_2",
upload: second_upload,
)
expect(serialized[:screenshot_url]).to eq(first_upload.url)
end
end
end end