Allowed avatar flair image file types

Not sure if this is by design, but does seem like a small bug.

When uploading avatar flair image, it allows selecting any image file type. However, if you select the wrong one Discourse will let you know with an error.

This is because Discourses uses wildcard in the accept attribute:
<input class="hidden-upload-field" accept="image/*" type="file">

Yet, only a few file types are actually allowed to be uploaded. I uploaded an SVG image, which is not allowed. You can see what’s allowed to be selected and what Discourse actually allows:

Ideally, accept attribute should specify all allowed file types instead of a wildcard.

Discourse 2.9.0.beta3 (03ad88f2c2)

5 Likes

Years later… this is actually a bit of a tricky one. We can do something like this.

commit ea5927e3ff15616fc7df91ddcd361e6379a12582
Author: Sam Saffron <sam.saffron@gmail.com>
Date:   Thu Oct 30 13:51:02 2025 +1100

    FEATURE: filter uploads correctly in uppy uploader and image  / avatar uploader

diff --git a/frontend/discourse/admin/components/images-uploader.gjs b/frontend/discourse/admin/components/images-uploader.gjs
index 18a857d581..978edaac30 100644
--- a/frontend/discourse/admin/components/images-uploader.gjs
+++ b/frontend/discourse/admin/components/images-uploader.gjs
@@ -2,13 +2,18 @@
 import Component from "@ember/component";
 import { getOwner } from "@ember/owner";
 import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { service } from "@ember/service";
 import { tagName } from "@ember-decorators/component";
 import icon from "discourse/helpers/d-icon";
+import { acceptedImageFormats } from "discourse/lib/uploads";
 import UppyUpload from "discourse/lib/uppy/uppy-upload";
 import { i18n } from "discourse-i18n";
 
 @tagName("span")
 export default class ImagesUploader extends Component {
+  @service currentUser;
+  @service siteSettings;
+
   uppyUpload = new UppyUpload(getOwner(this), {
     id: "images-uploader",
     type: "avatar",
@@ -28,6 +33,10 @@ export default class ImagesUploader extends Component {
     return this.uploadingOrProcessing ? i18n("uploading") : i18n("upload");
   }
 
+  get acceptedFormats() {
+    return acceptedImageFormats(this.currentUser?.staff, this.siteSettings);
+  }
+
   <template>
     <label
       class="btn"
@@ -40,7 +49,7 @@ export default class ImagesUploader extends Component {
         class="hidden-upload-field"
         disabled={{this.uppyUpload.uploading}}
         type="file"
-        accept="image/*"
+        accept={{this.acceptedFormats}}
         multiple
       />
     </label>
diff --git a/frontend/discourse/app/components/avatar-uploader.gjs b/frontend/discourse/app/components/avatar-uploader.gjs
index d3bf8fc341..0f77c9e06a 100644
--- a/frontend/discourse/app/components/avatar-uploader.gjs
+++ b/frontend/discourse/app/components/avatar-uploader.gjs
@@ -3,15 +3,20 @@ import Component from "@ember/component";
 import { action } from "@ember/object";
 import { getOwner } from "@ember/owner";
 import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { service } from "@ember/service";
 import { isBlank } from "@ember/utils";
 import { tagName } from "@ember-decorators/component";
 import DButton from "discourse/components/d-button";
 import discourseComputed from "discourse/lib/decorators";
+import { acceptedImageFormats } from "discourse/lib/uploads";
 import UppyUpload from "discourse/lib/uppy/uppy-upload";
 import { i18n } from "discourse-i18n";
 
 @tagName("span")
 export default class AvatarUploader extends Component {
+  @service currentUser;
+  @service siteSettings;
+
   uppyUpload = new UppyUpload(getOwner(this), {
     id: "avatar-uploader",
     type: "avatar",
@@ -34,6 +39,11 @@ export default class AvatarUploader extends Component {
 
   imageIsNotASquare = false;
 
+  @discourseComputed()
+  acceptedFormats() {
+    return acceptedImageFormats(this.currentUser?.staff, this.siteSettings);
+  }
+
   @discourseComputed("uppyUpload.uploading", "uploadedAvatarId")
   customAvatarUploaded() {
     return !this.uppyUpload.uploading && !isBlank(this.uploadedAvatarId);
@@ -58,7 +68,7 @@ export default class AvatarUploader extends Component {
       class="hidden-upload-field"
       disabled={{this.uploading}}
       type="file"
-      accept="image/*"
+      accept={{this.acceptedFormats}}
       aria-hidden="true"
     />
     <DButton
diff --git a/frontend/discourse/app/components/uppy-image-uploader.gjs b/frontend/discourse/app/components/uppy-image-uploader.gjs
index 6a94ae6cb8..28bf264e8a 100644
--- a/frontend/discourse/app/components/uppy-image-uploader.gjs
+++ b/frontend/discourse/app/components/uppy-image-uploader.gjs
@@ -15,7 +15,12 @@ import concatClass from "discourse/helpers/concat-class";
 import icon from "discourse/helpers/d-icon";
 import { getURLWithCDN } from "discourse/lib/get-url";
 import lightbox from "discourse/lib/lightbox";
-import { authorizesOneOrMoreExtensions, isVideo } from "discourse/lib/uploads";
+import {
+  acceptedImageFormats,
+  acceptedVideoFormats,
+  authorizesOneOrMoreExtensions,
+  isVideo,
+} from "discourse/lib/uploads";
 import UppyUpload from "discourse/lib/uppy/uppy-upload";
 import { i18n } from "discourse-i18n";
 
@@ -138,7 +143,20 @@ export default class UppyImageUploader extends Component {
   }
 
   get acceptedFormats() {
-    return this.args.allowVideo ? "image/*,video/*" : "image/*";
+    const imageFormats = acceptedImageFormats(
+      this.currentUser?.staff,
+      this.siteSettings
+    );
+
+    if (this.args.allowVideo) {
+      const videoFormats = acceptedVideoFormats(
+        this.currentUser?.staff,
+        this.siteSettings
+      );
+      return videoFormats ? `${imageFormats},${videoFormats}` : imageFormats;
+    }
+
+    return imageFormats;
   }
 
   get isVideoFile() {
diff --git a/frontend/discourse/app/lib/uploads.js b/frontend/discourse/app/lib/uploads.js
index a49005f6a8..da38f2abf4 100644
--- a/frontend/discourse/app/lib/uploads.js
+++ b/frontend/discourse/app/lib/uploads.js
@@ -178,6 +178,17 @@ function imagesExtensions(staff, siteSettings) {
   return exts;
 }
 
+function videosExtensions(staff, siteSettings) {
+  let exts = extensions(siteSettings).filter((ext) => isVideo(`.${ext}`));
+  if (staff) {
+    const staffExts = staffExtensions(siteSettings).filter((ext) =>
+      isVideo(`.${ext}`)
+    );
+    exts = exts.concat(staffExts);
+  }
+  return exts;
+}
+
 function isAuthorizedFile(fileName, staff, siteSettings) {
   if (
     staff &&
@@ -215,6 +226,16 @@ function authorizedImagesExtensions(staff, siteSettings) {
     : imagesExtensions(staff, siteSettings).join(", ");
 }
 
+export function acceptedImageFormats(staff, siteSettings) {
+  const exts = imagesExtensions(staff, siteSettings);
+  return exts.map((ext) => `.${ext}`).join(",");
+}
+
+export function acceptedVideoFormats(staff, siteSettings) {
+  const exts = videosExtensions(staff, siteSettings);
+  return exts.map((ext) => `.${ext}`).join(",");
+}
+
 export function authorizesAllExtensions(staff, siteSettings) {
   return (
     siteSettings.authorized_extensions.includes("*") ||

One slight downside is that our client side image compression can also sometimes handle format conversion. So potentially this would block some image types we don’t want to block.

I am mixed on if we want to fix this or not, but do see this as having high impact if you configured your site (for example) only to allow png.

cc @martin

From a quick look this seems okay to me, can you open up a PR and I will review?