diff --git a/app/assets/javascripts/admin/addon/components/file-size-input.gjs b/app/assets/javascripts/admin/addon/components/file-size-input.gjs new file mode 100644 index 00000000000..77df855b90c --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/file-size-input.gjs @@ -0,0 +1,150 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import TextField from "discourse/components/text-field"; +import { allowOnlyNumericInput } from "discourse/lib/utilities"; +import I18n from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +const UNIT_KB = "kb"; +const UNIT_MB = "mb"; +const UNIT_GB = "gb"; + +export default class FileSizeInput extends Component { + @tracked fileSizeUnit; + @tracked sizeValue; + @tracked pendingSizeValue; + @tracked pendingFileSizeUnit; + + constructor(owner, args) { + super(owner, args); + this.originalSizeKB = this.args.sizeValueKB; + this.sizeValue = this.args.sizeValueKB; + + this._defaultUnit(); + } + + _defaultUnit() { + this.fileSizeUnit = UNIT_KB; + if (this.originalSizeKB <= 1024) { + this.onFileSizeUnitChange(UNIT_KB); + } else if ( + this.originalSizeKB > 1024 && + this.originalSizeKB <= 1024 * 1024 + ) { + this.onFileSizeUnitChange(UNIT_MB); + } else if (this.originalSizeKB > 1024 * 1024) { + this.onFileSizeUnitChange(UNIT_GB); + } + } + + @action + keyDown(event) { + allowOnlyNumericInput(event); + } + + get dropdownOptions() { + return [ + { label: I18n.t("number.human.storage_units.units.kb"), value: UNIT_KB }, + { label: I18n.t("number.human.storage_units.units.mb"), value: UNIT_MB }, + { label: I18n.t("number.human.storage_units.units.gb"), value: UNIT_GB }, + ]; + } + + @action + handleFileSizeChange(value) { + if (value !== "") { + this.pendingSizeValue = value; + this._onFileSizeChange(value); + } + } + + _onFileSizeChange(newSize) { + let fileSizeKB; + switch (this.fileSizeUnit) { + case "kb": + fileSizeKB = newSize; + break; + case "mb": + fileSizeKB = newSize * 1024; + break; + case "gb": + fileSizeKB = newSize * 1024 * 1024; + break; + } + if (fileSizeKB > this.args.max) { + this.args.updateValidationMessage( + I18n.t("file_size_input.error.size_too_large", { + provided_file_size: I18n.toHumanSize(fileSizeKB * 1024), + max_file_size: I18n.toHumanSize(this.args.max * 1024), + }) + ); + // Removes the green save checkmark button + this.args.onChangeSize(this.originalSizeKB); + } else { + this.args.onChangeSize(fileSizeKB); + this.args.updateValidationMessage(null); + } + } + + @action + onFileSizeUnitChange(newUnit) { + if (this.fileSizeUnit === "kb" && newUnit === "kb") { + this.pendingSizeValue = this.sizeValue; + } + if (this.fileSizeUnit === "kb" && newUnit === "mb") { + this.pendingSizeValue = this.sizeValue / 1024; + } + if (this.fileSizeUnit === "kb" && newUnit === "gb") { + this.pendingSizeValue = this.sizeValue / 1024 / 1024; + } + if (this.fileSizeUnit === "mb" && newUnit === "kb") { + this.pendingSizeValue = this.sizeValue * 1024; + } + if (this.fileSizeUnit === "mb" && newUnit === "gb") { + this.pendingSizeValue = this.sizeValue / 1024; + } + if (this.fileSizeUnit === "gb" && newUnit === "mb") { + this.pendingSizeValue = this.sizeValue * 1024; + } + if (this.fileSizeUnit === "gb" && newUnit === "kb") { + this.pendingSizeValue = this.sizeValue * 1024 * 1024; + } + this.pendingFileSizeUnit = newUnit; + } + + @action + applySizeValueChanges() { + this.sizeValue = this.pendingSizeValue; + } + + @action + applyUnitChanges() { + this.fileSizeUnit = this.pendingFileSizeUnit; + } + + +} diff --git a/app/assets/javascripts/admin/addon/components/site-settings/file-size-restriction.gjs b/app/assets/javascripts/admin/addon/components/site-settings/file-size-restriction.gjs new file mode 100644 index 00000000000..9ec64ed6eff --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/site-settings/file-size-restriction.gjs @@ -0,0 +1,34 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import FileSizeInput from "admin/components/file-size-input"; +import SettingValidationMessage from "admin/components/setting-validation-message"; + +export default class FileSizeRestriction extends Component { + @tracked _validationMessage = this.args.validationMessage; + + @action + updateValidationMessage(message) { + this._validationMessage = message; + } + + get validationMessage() { + return this._validationMessage ?? this.args.validationMessage; + } + + +} diff --git a/app/assets/javascripts/admin/addon/mixins/setting-component.js b/app/assets/javascripts/admin/addon/mixins/setting-component.js index ab24c65993f..bb7b8e66317 100644 --- a/app/assets/javascripts/admin/addon/mixins/setting-component.js +++ b/app/assets/javascripts/admin/addon/mixins/setting-component.js @@ -33,6 +33,7 @@ const CUSTOM_TYPES = [ "simple_list", "emoji_list", "named_list", + "file_size_restriction", ]; const AUTO_REFRESH_ON_SAVE = ["logo", "logo_small", "large_icon"]; diff --git a/app/assets/javascripts/discourse/app/components/number-field.js b/app/assets/javascripts/discourse/app/components/number-field.js index 38838358ef8..c5236ca169a 100644 --- a/app/assets/javascripts/discourse/app/components/number-field.js +++ b/app/assets/javascripts/discourse/app/components/number-field.js @@ -1,36 +1,13 @@ import TextField from "discourse/components/text-field"; +import { allowOnlyNumericInput } from "discourse/lib/utilities"; import discourseComputed from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; -const ALLOWED_KEYS = [ - "Enter", - "Backspace", - "Tab", - "Delete", - "ArrowLeft", - "ArrowUp", - "ArrowRight", - "ArrowDown", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", -]; - export default TextField.extend({ classNameBindings: ["invalid"], keyDown: function (event) { - return ( - ALLOWED_KEYS.includes(event.key) || - (event.key === "-" && this._minNumber && this._minNumber < 0) - ); + allowOnlyNumericInput(event, this._minNumber && this._minNumber < 0); }, get _minNumber() { diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index 1b5903f045b..f666147a2a1 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -693,3 +693,34 @@ export function tokenRange(tokens, start, end) { return contents; } + +export function allowOnlyNumericInput(event, allowNegative = false) { + const ALLOWED_KEYS = [ + "Enter", + "Backspace", + "Tab", + "Delete", + "ArrowLeft", + "ArrowUp", + "ArrowRight", + "ArrowDown", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ]; + + if (!ALLOWED_KEYS.includes(event.key)) { + if (allowNegative && event.key === "-") { + return; + } else { + event.preventDefault(); + } + } +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/file-size-input-test.js b/app/assets/javascripts/discourse/tests/integration/components/file-size-input-test.js new file mode 100644 index 00000000000..1305c3e9d91 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/file-size-input-test.js @@ -0,0 +1,128 @@ +import { click, fillIn, render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +module("Integration | Component | file-size-input", function (hooks) { + setupRenderingTest(hooks); + + test("file size unit selector kb", async function (assert) { + this.set("value", 1024); + this.set("max", 4096); + this.set("onChangeSize", () => {}); + this.set("updateValidationMessage", () => {}); + + await render(hbs` + + `); + + assert.dom(".file-size-input").hasValue("1024", "value is present"); + }); + + test("file size unit selector", async function (assert) { + this.set("value", 4096); + this.set("max", 8192); + this.set("onChangeSize", () => {}); + this.set("updateValidationMessage", () => {}); + + await render(hbs` + + `); + + await click(".file-size-unit-selector"); + + await selectKit(".file-size-unit-selector").expand(); + await selectKit(".file-size-unit-selector").selectRowByValue("kb"); + + assert + .dom(".file-size-input") + .hasValue("4096", "value is change when then unit is changed"); + + await click(".file-size-unit-selector"); + + await selectKit(".file-size-unit-selector").expand(); + await selectKit(".file-size-unit-selector").selectRowByValue("mb"); + + assert + .dom(".file-size-input") + .hasValue("4", "value is changed when the unit is changed"); + + await click(".file-size-unit-selector"); + + await selectKit(".file-size-unit-selector").expand(); + await selectKit(".file-size-unit-selector").selectRowByValue("gb"); + + // TODO: Implement rounding or limit to X digits. + assert + .dom(".file-size-input") + .hasValue( + "0.00390625", + "value is changed when the unit is changed to gb" + ); + + await click(".file-size-unit-selector"); + + await selectKit(".file-size-unit-selector").expand(); + await selectKit(".file-size-unit-selector").selectRowByValue("mb"); + + assert + .dom(".file-size-input") + .hasValue( + "4", + "value is changed backed to original size with no decimal places" + ); + }); + + test("file size input error message", async function (assert) { + this.set("value", 4096); + this.set("max", 8192); + this.set("onChangeSize", () => {}); + + let updateValidationMessage = (message) => { + this.set("message", message); + }; + this.set("updateValidationMessage", updateValidationMessage); + + await render(hbs` + + `); + + await fillIn(".file-size-input", 12); + + assert.strictEqual( + this.message, + "12 MB is greater than the max allowed 8 MB", + "A message is showed when the input is greater than the max" + ); + + await fillIn(".file-size-input", 4); + + assert.strictEqual( + this.message, + null, + "The message is cleared when the input is less than the max" + ); + }); +}); diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 2b232752df9..c59c6645d37 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -14,6 +14,7 @@ @import "date-picker"; @import "date-time-input-range"; @import "date-time-input"; +@import "file-size-input"; @import "footer-nav"; @import "form-template-field"; @import "group-member-dropdown"; diff --git a/app/assets/stylesheets/common/components/file-size-input.scss b/app/assets/stylesheets/common/components/file-size-input.scss new file mode 100644 index 00000000000..00cb90587f4 --- /dev/null +++ b/app/assets/stylesheets/common/components/file-size-input.scss @@ -0,0 +1,15 @@ +.file-size-picker { + display: flex; + + .file-size-input { + flex: 1; + } + + .file-size-unit-selector { + flex: 0 0 8em; + } + + .select-kit .select-kit-header { + border-left: 0; + } +} diff --git a/app/controllers/admin/site_settings_controller.rb b/app/controllers/admin/site_settings_controller.rb index fe75884edc2..ba3054abde3 100644 --- a/app/controllers/admin/site_settings_controller.rb +++ b/app/controllers/admin/site_settings_controller.rb @@ -34,6 +34,8 @@ class Admin::SiteSettingsController < Admin::AdminController case SiteSetting.type_supervisor.get_type(id) when :integer value = value.tr("^-0-9", "") + when :file_size_restriction + value = value.tr("^-0-9", "") when :uploaded_image_list value = Upload.get_from_urls(value.split("|")).to_a end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ba25786bfcb..6457cb9a1c2 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2348,6 +2348,10 @@ en: from: From to: To + file_size_input: + error: + size_too_large: "%{provided_file_size} is greater than the max allowed %{max_file_size}" + emoji_picker: filter_placeholder: Search for emoji smileys_&_emotion: Smileys and Emotion diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 859c64510b3..664f1ea7581 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2017,8 +2017,8 @@ en: default_dark_mode_color_scheme_id: "The color scheme used when in dark mode." dark_mode_none: "None" - 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. Images larger than this and smaller than client_max_body_size will be resized to fit on upload." - 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." + max_image_size_kb: "The maximum image upload size. This must be configured in nginx (client_max_body_size) / apache or proxy as well. Images larger than this and smaller than client_max_body_size will be resized to fit on upload." + max_attachment_size_kb: "The maximum attachment files upload size. 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)" authorized_extensions_for_staff: "A list of file extensions allowed for upload for staff users in addition to the list defined in the `authorized_extensions` site setting. (use '*' to enable all file types)" theme_authorized_extensions: "A list of file extensions allowed for theme uploads (use '*' to enable all file types)" @@ -2125,7 +2125,7 @@ en: always_show_trimmed_content: "Always show trimmed part of incoming emails. WARNING: might reveal email addresses." trim_incoming_emails: "Trim part of the incoming emails that isn't relevant." private_email: "Don't include content from posts or topics in email title or email body. NOTE: also disables digest emails." - email_total_attachment_size_limit_kb: "Max total size of files attached to outgoing emails in kB. Set to 0 to disable sending of attachments." + email_total_attachment_size_limit_kb: "Max total size of files attached to outgoing emails. Set to 0 to disable sending of attachments." post_excerpts_in_emails: "In notification emails, always send excerpts instead of full posts" raw_email_max_length: "How many characters should be stored for incoming email." raw_rejected_email_max_length: "How many characters should be stored for rejected incoming email." diff --git a/config/site_settings.yml b/config/site_settings.yml index 1d19b5ab8fd..fd6a3746f1f 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1396,6 +1396,7 @@ email: email_total_attachment_size_limit_kb: default: 0 max: 51200 + type: file_size_restriction post_excerpts_in_emails: false raw_email_max_length: 220000 raw_rejected_email_max_length: 4000 @@ -1416,10 +1417,12 @@ files: client: true default: 4096 max: 102400 + type: file_size_restriction max_attachment_size_kb: client: true default: 4096 max: 1024000 + type: file_size_restriction max_image_megapixels: default: 40 min: 5 @@ -1428,6 +1431,7 @@ files: hidden: true default: 50000 max: 1024000 + type: file_size_restriction theme_authorized_extensions: default: "wasm|jpg|jpeg|png|woff|woff2|svg|eot|ttf|otf|gif|webp|avif|js" type: list @@ -1490,6 +1494,7 @@ files: default: 1024 min: 1 max: 10240 + type: file_size_restriction secure_uploads_pm_only: default: false hidden: true diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb index 938fda2bf30..151a0c0ec92 100644 --- a/lib/site_settings/type_supervisor.rb +++ b/lib/site_settings/type_supervisor.rb @@ -55,6 +55,7 @@ class SiteSettings::TypeSupervisor emoji_list: 24, html_deprecated: 25, tag_group_list: 26, + file_size_restriction: 27, ) end @@ -180,7 +181,7 @@ class SiteSettings::TypeSupervisor end end - if type == :integer + if type == :integer || type == :file_size_restriction result[:min] = @validators[name].dig(:opts, :min) if @validators[name].dig( :opts, :min, @@ -304,6 +305,8 @@ class SiteSettings::TypeSupervisor GroupSettingValidator when self.class.types[:integer] IntegerSettingValidator + when self.class.types[:file_size_restriction] + IntegerSettingValidator when self.class.types[:regex] RegexSettingValidator when self.class.types[:string], self.class.types[:list], self.class.types[:enum] diff --git a/spec/lib/shrink_uploaded_image_spec.rb b/spec/lib/shrink_uploaded_image_spec.rb index 08a533cef85..58b5522a69d 100644 --- a/spec/lib/shrink_uploaded_image_spec.rb +++ b/spec/lib/shrink_uploaded_image_spec.rb @@ -86,7 +86,7 @@ RSpec.describe ShrinkUploadedImage do it "returns false when the upload is above the size limit" do post = Fabricate(:post, raw: "") post.link_post_uploads - SiteSetting.max_image_size_kb = 0.001 # 1 byte + SiteSetting.max_image_size_kb = 0 result = ShrinkUploadedImage.new( diff --git a/spec/tasks/uploads_spec.rb b/spec/tasks/uploads_spec.rb index f2470377deb..2b251b22ae6 100644 --- a/spec/tasks/uploads_spec.rb +++ b/spec/tasks/uploads_spec.rb @@ -240,7 +240,7 @@ RSpec.describe "tasks/uploads" do it "updates attributes of uploads that are over the size limit" do upload.update!(thumbnail_height: 0) - SiteSetting.max_image_size_kb = 0.001 # 1 byte + SiteSetting.max_image_size_kb = 1 expect { invoke_task }.to change { upload.reload.thumbnail_height }.to(200) end