FEATURE: support uploads for themes

This allows themes to bundle various assets
This commit is contained in:
Sam 2017-05-09 17:20:28 -04:00
parent f709899a1d
commit bc0b9af576
25 changed files with 368 additions and 51 deletions

View File

@ -1,6 +1,9 @@
import { default as computed } from 'ember-addons/ember-computed-decorators';
import { url } from 'discourse/lib/computed';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import showModal from 'discourse/lib/show-modal';
const THEME_UPLOAD_VAR = 2;
export default Ember.Controller.extend({
@ -96,6 +99,16 @@ export default Ember.Controller.extend({
});
},
addUploadModal() {
showModal('admin-add-upload', {admin: true, name: ''});
},
addUpload(info) {
let model = this.get("model");
model.setField('common', info.name, '', info.upload_id, THEME_UPLOAD_VAR);
model.saveChanges('theme_fields').catch(e => popupAjaxError(e));
},
cancelChangeScheme() {
this.set("colorSchemeId", this.get("model.color_scheme_id"));
},
@ -154,6 +167,10 @@ export default Ember.Controller.extend({
this.get("model").addChildTheme(theme);
},
removeUpload(upload) {
this.get("model").removeField(upload);
},
removeChildTheme(theme) {
this.get("model").removeChildTheme(theme);
},

View File

@ -0,0 +1,43 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import { ajax } from 'discourse/lib/ajax';
// import computed from 'ember-addons/ember-computed-decorators';
import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.Controller.extend(ModalFunctionality, {
adminCustomizeThemesShow: Ember.inject.controller(),
actions: {
updateName() {
let name = this.get('name');
if (Em.isEmpty(name)) {
name = $('#file-input')[0].files[0].name;
this.set('name', name.split(".")[0]);
}
},
upload() {
let options = {
type: 'POST'
};
options.processData = false;
options.contentType = false;
options.data = new FormData();
let file = $('#file-input')[0].files[0];
options.data.append('file', file);
ajax('/admin/themes/upload_asset', options).then(result=>{
let upload = {
upload_id: result.upload_id,
name: this.get('name'),
original_filename: file.name
};
this.get('adminCustomizeThemesShow').send('addUpload', upload);
this.send('closeModal');
}).catch(e => {
popupAjaxError(e);
});
}
}
});

View File

@ -1,6 +1,8 @@
import RestModel from 'discourse/models/rest';
import { default as computed } from 'ember-addons/ember-computed-decorators';
const THEME_UPLOAD_VAR = 2;
const Theme = RestModel.extend({
@computed('theme_fields')
@ -14,12 +16,26 @@ const Theme = RestModel.extend({
let hash = {};
if (fields) {
fields.forEach(field=>{
hash[field.target + " " + field.name] = field;
if (!field.type_id || field.type_id < THEME_UPLOAD_VAR) {
hash[this.getKey(field)] = field;
}
});
}
return hash;
},
@computed('theme_fields', 'theme_fields.@each')
uploads(fields) {
if (!fields) {
return [];
}
return fields.filter((f)=> f.target === 'common' && f.type_id === THEME_UPLOAD_VAR);
},
getKey(field){
return field.target + " " + field.name;
},
hasEdited(target, name){
if (name) {
return !Em.isEmpty(this.getField(target, name));
@ -31,30 +47,56 @@ const Theme = RestModel.extend({
getError(target, name) {
let themeFields = this.get("themeFields");
let key = target + " " + name;
let key = this.getKey({target,name});
let field = themeFields[key];
return field ? field.error : "";
},
getField(target, name) {
let themeFields = this.get("themeFields");
let key = target + " " + name;
let key = this.getKey({target, name})
let field = themeFields[key];
return field ? field.value : "";
},
setField(target, name, value) {
removeField(field) {
this.set("changed", true);
field.upload_id = null;
field.value = null;
return this.saveChanges("theme_fields");
},
setField(target, name, value, upload_id, type_id) {
this.set("changed", true);
let themeFields = this.get("themeFields");
let key = target + " " + name;
let field = themeFields[key];
if (!field) {
field = {name, target, value};
let field = {name, target, value, upload_id, type_id};
// slow path for uploads and so on
if (type_id && type_id > 1) {
let fields = this.get("theme_fields");
let existing = fields.find((f) =>
f.target === target &&
f.name === name &&
f.type_id === type_id);
if (existing) {
existing.value = value;
existing.upload_id = upload_id;
} else {
fields.push(field);
}
return;
}
// fast path
let key = this.getKey({target,name});
let existingField = themeFields[key];
if (!existingField) {
this.theme_fields.push(field);
themeFields[key] = field;
} else {
field.value = value;
existingField.value = value;
}
},

View File

@ -51,7 +51,6 @@
<h3>{{i18n "admin.customize.theme.css_html"}}</h3>
{{#if hasEditedFields}}
<p>{{i18n "admin.customize.theme.custom_sections"}}</p>
<ul>
{{#each editedDescriptions as |desc|}}
@ -87,6 +86,21 @@
{{/if}}
</p>
<h3>{{i18n "admin.customize.theme.uploads"}}</h3>
{{#if model.uploads}}
<ul class='removable-list'>
{{#each model.uploads as |upload|}}
<li><span class='first'>${{upload.name}}: <a href={{upload.url}} target='_blank'>{{upload.filename}}</a></span>{{d-button action="removeUpload" actionParam=upload class="second btn-small cancel-edit" icon="times"}}</li>
{{/each}}
</ul>
{{else}}
<p>{{i18n "admin.customize.theme.no_uploads"}}</p>
{{/if}}
<p>
{{#d-button action="addUploadModal" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
</p>
{{#if availableChildThemes}}
<h3>{{i18n "admin.customize.theme.theme_components"}}</h3>
{{#unless model.childThemes.length}}
@ -97,9 +111,9 @@
</label>
</p>
{{else}}
<ul>
<ul class='removable-list'>
{{#each model.childThemes as |child|}}
<li>{{#link-to 'adminCustomizeThemes.show' child replace=true}}{{child.name}}{{/link-to}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit" icon="times"}}</li>
<li>{{#link-to 'adminCustomizeThemes.show' child replace=true class='first'}}{{child.name}}{{/link-to}} {{d-button action="removeChildTheme" actionParam=child class="btn-small cancel-edit second" icon="times"}}</li>
{{/each}}
</ul>
{{/unless}}

View File

@ -0,0 +1,12 @@
{{#d-modal-body class='upload-selector' title="admin.customize.theme.add_upload"}}
<div class="inputs">
<input id="name" placeholder={{i18n 'admin.customize.theme.upload_name'}} value={{name}}><br>
<input onchange={{action "updateName"}} type="file" id="file-input" accept='*'><br>
<span class="description">{{i18n 'admin.customize.theme.upload_file_tip'}}</span>
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action="upload" disabled=loading class='btn btn-primary' icon='upload' label='admin.customize.theme.upload'}}
<a href {{action "closeModal"}}>{{i18n 'cancel'}}</a>
</div>

View File

@ -187,5 +187,21 @@
font-size: 0.8em;
margin-top: 8px;
}
.removable-list {
list-style: none;
margin-left: 0;
li {
display: table-row;
.first {
padding-right: 8px;
padding-bottom: 10px;
min-width: 100px;
}
.first, .second {
display: table-cell;
}
}
}
}

View File

@ -8,6 +8,22 @@ class Admin::ThemesController < Admin::AdminController
redirect_to path("/"), flash: {preview_theme_key: @theme.key}
end
def upload_asset
path = params[:file].path
File.open(path) do |file|
upload = Upload.create_for(current_user.id,
file,
params[:original_filename] || File.basename(path),
File.size(path),
for_theme: true)
if upload.errors.count > 0
render json: upload.errors, status: :unprocessable_entity
else
render json: {upload_id: upload.id}, status: :created
end
end
end
def import
@theme = nil
@ -184,7 +200,7 @@ class Admin::ThemesController < Admin::AdminController
:color_scheme_id,
:default,
:user_selectable,
theme_fields: [:name, :target, :value],
theme_fields: [:name, :target, :value, :upload_id, :type_id],
child_theme_ids: [])
end
end
@ -194,7 +210,13 @@ class Admin::ThemesController < Admin::AdminController
return unless fields = theme_params[:theme_fields]
fields.each do |field|
@theme.set_field(target: field[:target], name: field[:name], value: field[:value], type_id: field[:type_id])
@theme.set_field(
target: field[:target],
name: field[:name],
value: field[:value],
type_id: field[:type_id],
upload_id: field[:upload_id]
)
end
end

View File

@ -46,6 +46,34 @@ class RemoteTheme < ActiveRecord::Base
importer.import!
end
theme_info = JSON.parse(importer["about.json"])
theme_info["assets"]&.each do |name, relative_path|
if path = importer.real_path(relative_path)
upload = Upload.create_for(theme.user_id, File.open(path), File.basename(relative_path), File.size(path), for_theme: true)
theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id)
end
end
theme_info["fields"]&.each do |name, info|
unless Hash === info
info = {
"target" => :common,
"type" => :theme_var,
"value" => info
}
end
if info["type"] == "color"
info["type"] = :theme_color_var
end
theme.set_field(target: info["target"] || :common,
name: name,
value: info["value"],
type: info["type"] || :theme_var)
end
Theme.targets.keys.each do |target|
ALLOWED_FIELDS.each do |field|
lookup =
@ -62,7 +90,6 @@ class RemoteTheme < ActiveRecord::Base
end
end
theme_info = JSON.parse(importer["about.json"])
self.license_url ||= theme_info["license_url"]
self.about_url ||= theme_info["about_url"]
self.remote_updated_at = Time.zone.now

View File

@ -252,19 +252,16 @@ class Theme < ActiveRecord::Base
type_id ||= type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name)
raise "Unknown type #{type} passed to set field" unless type_id
if upload_id && !value
value = ""
end
raise "Missing value for theme field" unless value
value ||= ""
field = theme_fields.find{|f| f.name==name && f.target_id == target_id && f.type_id == type_id}
if field
if value.blank?
if value.blank? && !upload_id
theme_fields.delete field.destroy
else
if field.value != value
if field.value != value || field.upload_id != upload_id
field.value = value
field.upload_id = upload_id
changed_fields << field
end
end

View File

@ -112,7 +112,9 @@ COMPILED
begin
Stylesheet::Compiler.compile("@import \"theme_variables\"; @import \"theme_field\";",
"theme.scss",
theme_field: self.value.dup)
theme_field: self.value.dup,
theme: self.theme
)
self.error = nil unless error.nil?
rescue SassC::SyntaxError => e
self.error = e.message

View File

@ -15,6 +15,7 @@ class Upload < ActiveRecord::Base
has_many :optimized_images, dependent: :destroy
attr_accessor :is_attachment_for_group_message
attr_accessor :for_theme
validates_presence_of :filesize
validates_presence_of :original_filename
@ -38,7 +39,7 @@ class Upload < ActiveRecord::Base
crop: crop
}
if thumbnail = OptimizedImage.create_for(self, width, height, opts)
if _thumbnail = OptimizedImage.create_for(self, width, height, opts)
self.width = width
self.height = height
save(validate: false)
@ -99,6 +100,7 @@ class Upload < ActiveRecord::Base
# - origin (url)
# - image_type ("avatar", "profile_background", "card_background", "custom_emoji")
# - is_attachment_for_group_message (boolean)
# - for_theme (boolean)
def self.create_for(user_id, file, filename, filesize, options = {})
upload = Upload.new
@ -196,6 +198,10 @@ class Upload < ActiveRecord::Base
upload.is_attachment_for_group_message = true
end
if options[:for_theme]
upload.for_theme = true
end
if is_dimensionless_image?(filename, upload.width, upload.height)
upload.errors.add(:base, I18n.t("upload.images.size_not_found"))
return upload

View File

@ -1,5 +1,25 @@
class ThemeFieldSerializer < ApplicationSerializer
attributes :name, :target, :value, :error, :type_id
attributes :name, :target, :value, :error, :type_id, :upload_id, :url, :filename
def include_url?
object.upload
end
def include_upload_id?
object.upload
end
def include_filename?
object.upload
end
def url
object.upload&.url
end
def filename
object.upload&.original_filename
end
def target
case object.target_id

View File

@ -2841,10 +2841,16 @@ en:
color_scheme_select: "Select colors to be used by theme"
custom_sections: "Custom sections:"
theme_components: "Theme Components"
uploads: "Uploads"
no_uploads: "You can upload assets associated with your theme such as fonts and images"
add_upload: "Add Upload"
upload_file_tip: "Choose an asset to upload (png, woff2, etc...)"
upload: "Upload"
child_themes_check: "Theme includes other child themes"
css_html: "Custom CSS/HTML"
edit_css_html: "Edit CSS/HTML"
edit_css_html_help: "You have not edited any CSS or HTML"
upload_name: "Name"
import_web_tip: "Repository containing theme"
import_file_tip: ".dcstyle.json file containing theme"
about_theme: "About Theme"

View File

@ -1255,6 +1255,7 @@ en:
max_image_size_kb: "The maximum image upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
max_attachment_size_kb: "The maximum attachment files upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well."
authorized_extensions: "A list of file extensions allowed for upload (use '*' to enable all file types)"
theme_authorized_extensions: "A list of file extensions allowed for theme uploads (use '*' to enable all file types)"
max_similar_results: "How many similar topics to show above the editor when composing a new topic. Comparison is based on title and body."
max_image_megapixels: "Maximum number of megapixels allowed for an image."

View File

@ -189,6 +189,7 @@ Discourse::Application.routes.draw do
resources :themes, constraints: AdminConstraint.new
post "themes/import" => "themes#import"
post "themes/upload_asset" => "themes#upload_asset"
get "themes/:id/preview" => "themes#preview"
scope "/customize", constraints: AdminConstraint.new do

View File

@ -713,6 +713,9 @@ files:
default: 40
min: 5
max: 150
theme_authorized_extensions:
default: 'jpg|jpeg|png|woff|woff2|svg|eot|ttf|otf'
type: list
authorized_extensions:
client: true
default: 'jpg|jpeg|png|gif'

View File

@ -35,15 +35,24 @@ class GitImporter
FileUtils.rm_rf(@temp_folder)
end
def [](value)
fullpath = "#{@temp_folder}/#{value}"
def real_path(relative)
fullpath = "#{@temp_folder}/#{relative}"
return nil unless File.exist?(fullpath)
# careful to handle symlinks here, don't want to expose random data
fullpath = Pathname.new(fullpath).realpath.to_s
if fullpath && fullpath.start_with?(@temp_folder)
File.read(fullpath)
fullpath
else
nil
end
end
def [](value)
fullpath = real_path(value)
return nil unless fullpath
File.read(fullpath)
end
end

View File

@ -40,6 +40,7 @@ module Stylesheet
source_map_file: source_map_file,
source_map_contents: true,
theme_id: options[:theme_id],
theme: options[:theme],
theme_field: options[:theme_field],
load_paths: [ASSET_ROOT])

View File

@ -41,7 +41,9 @@ module Stylesheet
colors.each do |n, hex|
contents << "$#{n}: ##{hex} !default;\n"
end
theme&.theme_fields&.where(type_id: ThemeField.theme_var_type_ids)&.each do |field|
theme&.theme_fields&.each do |field|
next unless ThemeField.theme_var_type_ids.include?(field.type_id)
if field.type_id == ThemeField.types[:theme_upload_var]
if upload = field.upload
url = upload_cdn_path(upload.url)
@ -84,8 +86,13 @@ module Stylesheet
end
def initialize(options)
@theme = options[:theme]
@theme_id = options[:theme_id]
@theme_field = options[:theme_field]
if @theme && !@theme_id
# make up an id so other stuff does not bail out
@theme_id = @theme.id || -1
end
end
def import_files(files)

View File

@ -24,11 +24,11 @@ class Validators::UploadValidator < ActiveModel::Validator
end
def is_authorized?(upload, extension)
authorized_extensions(upload, extension, authorized_uploads)
authorized_extensions(upload, extension, authorized_uploads(upload))
end
def authorized_image_extension(upload, extension)
authorized_extensions(upload, extension, authorized_images)
authorized_extensions(upload, extension, authorized_images(upload))
end
def maximum_image_file_size(upload)
@ -36,7 +36,7 @@ class Validators::UploadValidator < ActiveModel::Validator
end
def authorized_attachment_extension(upload, extension)
authorized_extensions(upload, extension, authorized_attachments)
authorized_extensions(upload, extension, authorized_attachments(upload))
end
def maximum_attachment_file_size(upload)
@ -45,10 +45,12 @@ class Validators::UploadValidator < ActiveModel::Validator
private
def authorized_uploads
def authorized_uploads(upload)
authorized_uploads = Set.new
SiteSetting.authorized_extensions
extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions
extensions
.gsub(/[\s\.]+/, "")
.downcase
.split("|")
@ -57,20 +59,21 @@ class Validators::UploadValidator < ActiveModel::Validator
authorized_uploads
end
def authorized_images
authorized_uploads & FileHelper.images
def authorized_images(upload)
authorized_uploads(upload) & FileHelper.images
end
def authorized_attachments
authorized_uploads - FileHelper.images
def authorized_attachments(upload)
authorized_uploads(upload) - FileHelper.images
end
def authorizes_all_extensions?
SiteSetting.authorized_extensions.include?("*")
def authorizes_all_extensions?(upload)
extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions
extensions.include?("*")
end
def authorized_extensions(upload, extension, extensions)
return true if authorizes_all_extensions?
return true if authorizes_all_extensions?(upload)
unless authorized = extensions.include?(extension.downcase)
message = I18n.t("upload.unauthorized", authorized_extensions: extensions.to_a.join(", "))

View File

@ -11,6 +11,26 @@ describe Admin::ThemesController do
@user = log_in(:admin)
end
context '.upload_asset' do
render_views
let(:upload) do
ActionDispatch::Http::UploadedFile.new({
filename: 'test.woff2',
tempfile: file_from_fixtures("fake.woff2", "woff2")
})
end
it 'can create a theme upload' do
xhr :post, :upload_asset, file: upload, original_filename: 'wooof.woff2'
expect(response.status).to eq(201)
upload = Upload.find_by(original_filename: "wooof.woff2")
expect(upload.id).not_to be_nil
expect(JSON.parse(response.body)["upload_id"]).to eq(upload.id)
end
end
context '.import' do
let(:theme_file) do
ActionDispatch::Http::UploadedFile.new({
@ -93,30 +113,35 @@ describe Admin::ThemesController do
end
it 'updates a theme' do
#focus
theme = Theme.new(name: 'my name', user_id: -1)
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
theme.save
child_theme = Theme.create(name: 'my name', user_id: -1)
upload = Fabricate(:upload)
xhr :put, :update, id: theme.id,
theme: {
child_theme_ids: [child_theme.id],
name: 'my test name',
theme_fields: [
{ name: 'scss', target: 'common', value: '' },
{ name: 'scss', target: 'desktop', value: 'body{color: blue;}' }
{ name: 'scss', target: 'desktop', value: 'body{color: blue;}' },
{ name: 'bob', target: 'common', value: '', type_id: 2, upload_id: upload.id },
]
}
expect(response).to be_success
json = ::JSON.parse(response.body)
fields = json["theme"]["theme_fields"]
fields = json["theme"]["theme_fields"].sort{|a,b| a["value"] <=> b["value"]}
expect(fields.first["value"]).to eq('body{color: blue;}')
expect(fields.length).to eq(1)
expect(fields[0]["value"]).to eq('')
expect(fields[0]["upload_id"]).to eq(upload.id)
expect(fields[1]["value"]).to eq('body{color: blue;}')
expect(fields.length).to eq(2)
expect(json["theme"]["child_themes"].length).to eq(1)

1
spec/fixtures/woff2/fake.woff2 vendored Normal file
View File

@ -0,0 +1 @@
not a woff

View File

@ -9,7 +9,7 @@ describe RemoteTheme do
`cd #{repo_dir} && git init . `
`cd #{repo_dir} && git config user.email 'someone@cool.com'`
`cd #{repo_dir} && git config user.name 'The Cool One'`
`cd #{repo_dir} && mkdir desktop mobile common`
`cd #{repo_dir} && mkdir desktop mobile common assets`
files.each do |name, data|
File.write("#{repo_dir}/#{name}", data)
`cd #{repo_dir} && git add #{name}`
@ -26,6 +26,17 @@ describe RemoteTheme do
"name": "awesome theme",
"about_url": "https://www.site.com/about",
"license_url": "https://www.site.com/license",
"assets": {
"font": "assets/awesome.woff2"
},
"fields": {
"color": {
"target": "desktop",
"value": "#FEF",
"type": "color"
},
"name": "sam"
},
"color_schemes": {
"Amazing": {
"love": "#{options[:love]}"
@ -35,13 +46,18 @@ describe RemoteTheme do
JSON
end
let :scss_data do
"@font-face { font-family: magic; src: url($font)}; body {color: $color; content: $name;}"
end
let :initial_repo do
setup_git_repo(
"about.json" => about_json,
"desktop/desktop.scss" => "body {color: red;}",
"desktop/desktop.scss" => scss_data,
"common/header.html" => "I AM HEADER",
"common/random.html" => "I AM SILLY",
"common/embedded.scss" => "EMBED",
"assets/awesome.woff2" => "FAKE FONT",
)
end
@ -65,14 +81,20 @@ JSON
expect(remote.about_url).to eq("https://www.site.com/about")
expect(remote.license_url).to eq("https://www.site.com/license")
expect(@theme.theme_fields.length).to eq(3)
expect(@theme.theme_fields.length).to eq(6)
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
expect(mapped["0-header"]).to eq("I AM HEADER")
expect(mapped["1-scss"]).to eq("body {color: red;}")
expect(mapped["1-scss"]).to eq(scss_data)
expect(mapped["0-embedded_scss"]).to eq("EMBED")
expect(mapped["1-color"]).to eq("#FEF")
expect(mapped["0-font"]).to eq("")
expect(mapped["0-name"]).to eq("sam")
expect(mapped.length).to eq(6)
expect(remote.remote_updated_at).to eq(time)
scheme = ColorScheme.find_by(theme_id: @theme.id)
@ -104,7 +126,7 @@ JSON
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target_id}-#{f.name}", f.value]}.flatten]
expect(mapped["0-header"]).to eq("I AM UPDATED")
expect(mapped["1-scss"]).to eq("body {color: red;}")
expect(mapped["1-scss"]).to eq(scss_data)
expect(remote.remote_updated_at).to eq(time)
end

View File

@ -182,6 +182,9 @@ HTML
expect(Upload.where(id: upload.id)).to be_exist
# no error for theme field
theme.reload
expect(theme.theme_fields.find_by(name: :scss).error).to eq(nil)
scss,_map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
expect(scss).to include(upload.url)

View File

@ -0,0 +1,17 @@
import Theme from 'admin/models/theme';
module("model:theme");
test('can add an upload correctly', function(assert) {
let theme = Theme.create();
assert.equal(theme.get("uploads.length"), 0, "uploads should be an empty array");
theme.setField('common', 'bob', '', 999, 2);
let fields = theme.get("theme_fields");
assert.equal(fields.length, 1, 'expecting 1 theme field');
assert.equal(fields[0].upload_id, 999, 'expecting upload id to be set');
assert.equal(fields[0].type_id, 2, 'expecting type id to be set');
assert.equal(theme.get("uploads.length"), 1, "expecting an upload");
});