From cda44750cbdc7a8460666a4f0ac7f652d84a3964 Mon Sep 17 00:00:00 2001
From: silverwind <me@silverwind.io>
Date: Mon, 5 Oct 2020 07:49:33 +0200
Subject: [PATCH] Attachments: Add extension support, allow all types for
 releases (#12465)

* Attachments: Add extension support, allow all types for releases

- Add support for file extensions, matching the `accept` attribute of `<input type="file">`
- Add support for type wildcard mime types, e.g. `image/*`
- Create repository.release.ALLOWED_TYPES setting (default unrestricted)
- Change default for attachment.ALLOWED_TYPES to a list of extensions
- Split out POST /attachments into two endpoints for issue/pr and
  releases to prevent circumvention of allowed types check

Fixes: https://github.com/go-gitea/gitea/pull/10172
Fixes: https://github.com/go-gitea/gitea/issues/7266
Fixes: https://github.com/go-gitea/gitea/pull/12460
Ref: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers

* rename function

* extract GET routes out of RepoMustNotBeArchived

Co-authored-by: Lauris BH <lauris@nix.lv>
---
 custom/conf/app.example.ini                   |  13 +-
 .../doc/advanced/config-cheat-sheet.en-us.md  |  19 +-
 integrations/attachment_test.go               |   2 +-
 models/twofactor.go                           |  51 +----
 modules/secret/secret.go                      |  68 ++++++
 modules/secret/secret_test.go                 |  13 ++
 modules/setting/attachment.go                 |   3 +-
 modules/setting/repository.go                 |  16 +-
 modules/upload/filetype.go                    |  46 -----
 modules/upload/filetype_test.go               |  47 -----
 modules/upload/upload.go                      |  94 +++++++++
 modules/upload/upload_test.go                 | 195 ++++++++++++++++++
 routers/api/v1/repo/release_attachment.go     |   3 +-
 routers/repo/attachment.go                    |  21 +-
 routers/repo/compare.go                       |   4 +-
 routers/repo/editor.go                        |  26 +--
 routers/repo/issue.go                         |  12 +-
 routers/repo/pull.go                          |   4 +-
 routers/repo/release.go                       |   7 +-
 routers/routes/routes.go                      |  19 +-
 templates/repo/editor/upload.tmpl             |   2 +-
 templates/repo/issue/comment_tab.tmpl         |   8 +-
 templates/repo/issue/view_content.tmpl        |  15 +-
 templates/repo/release/new.tmpl               |   8 +-
 templates/repo/upload.tmpl                    |  13 ++
 web_src/js/index.js                           |  14 +-
 26 files changed, 497 insertions(+), 226 deletions(-)
 delete mode 100644 modules/upload/filetype.go
 delete mode 100644 modules/upload/filetype_test.go
 create mode 100644 modules/upload/upload.go
 create mode 100644 modules/upload/upload_test.go
 create mode 100644 templates/repo/upload.tmpl

diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 1fc2c9ef0f..44c448a4e3 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -88,7 +88,7 @@ LOCAL_COPY_PATH = tmp/local-repo
 ENABLED = true
 ; Path for uploads. Defaults to `data/tmp/uploads` (tmp gets deleted on gitea restart)
 TEMP_PATH = data/tmp/uploads
-; One or more allowed types, e.g. image/jpeg|image/png. Nothing means any file type
+; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
 ALLOWED_TYPES =
 ; Max size of each file in megabytes. Defaults to 3MB
 FILE_MAX_SIZE = 3
@@ -117,6 +117,10 @@ DEFAULT_MERGE_MESSAGE_OFFICIAL_APPROVERS_ONLY=true
 ; List of reasons why a Pull Request or Issue can be locked
 LOCK_REASONS=Too heated,Off-topic,Resolved,Spam
 
+[repository.release]
+; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
+ALLOWED_TYPES =
+
 [repository.signing]
 ; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey
 ; run in the context of the RUN_USER
@@ -766,11 +770,10 @@ DISABLE_GRAVATAR = false
 ENABLE_FEDERATED_AVATAR = false
 
 [attachment]
-; Whether attachments are enabled. Defaults to `true`
+; Whether issue and pull request attachments are enabled. Defaults to `true`
 ENABLED = true
-
-; One or more allowed types, e.g. "image/jpeg|image/png". Use "*/*" for all types.
-ALLOWED_TYPES = image/jpeg|image/png|application/zip|application/gzip
+; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
+ALLOWED_TYPES = .docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip
 ; Max size of each file. Defaults to 4MB
 MAX_SIZE = 4
 ; Max number of files per upload. Defaults to 5
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index fbf7affeaf..dc3979a64d 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -101,6 +101,18 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
 
 - `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked
 
+### Repository - Upload (`repository.upload`)
+
+- `ENABLED`: **true**: Whether repository file uploads are enabled
+- `TEMP_PATH`: **data/tmp/uploads**: Path for uploads (tmp gets deleted on gitea restart)
+- `ALLOWED_TYPES`: **\<empty\>**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
+- `FILE_MAX_SIZE`: **3**: Max size of each file in megabytes.
+- `MAX_FILES`: **5**: Max number of files per upload
+
+### Repository - Release (`repository.release`)
+
+- `ALLOWED_TYPES`: **\<empty\>**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
+
 ### Repository - Signing (`repository.signing`)
 
 - `SIGNING_KEY`: **default**: \[none, KEYID, default \]: Key to sign with.
@@ -560,11 +572,10 @@ Default templates for project boards:
 - `PROJECT_BOARD_BASIC_KANBAN_TYPE`: **To Do, In Progress, Done**
 - `PROJECT_BOARD_BUG_TRIAGE_TYPE`: **Needs Triage, High Priority, Low Priority, Closed**
 
-## Attachment (`attachment`)
+## Issue and pull request attachments (`attachment`)
 
-- `ENABLED`: **true**: Enable this to allow uploading attachments.
-- `ALLOWED_TYPES`: **see app.example.ini**: Allowed MIME types, e.g. `image/jpeg|image/png`.
-   Use `*/*` for all types.
+- `ENABLED`: **true**: Whether issue and pull request attachments are enabled.
+- `ALLOWED_TYPES`: **.docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
 - `MAX_SIZE`: **4**: Maximum size (MB).
 - `MAX_FILES`: **5**: Maximum number of attachments that can be uploaded at once.
 - `STORAGE_TYPE`: **local**: Storage type for attachments, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]`
diff --git a/integrations/attachment_test.go b/integrations/attachment_test.go
index 7219adf7d7..dd734145d2 100644
--- a/integrations/attachment_test.go
+++ b/integrations/attachment_test.go
@@ -43,7 +43,7 @@ func createAttachment(t *testing.T, session *TestSession, repoURL, filename stri
 
 	csrf := GetCSRF(t, session, repoURL)
 
-	req := NewRequestWithBody(t, "POST", "/attachments", body)
+	req := NewRequestWithBody(t, "POST", repoURL+"/issues/attachments", body)
 	req.Header.Add("X-Csrf-Token", csrf)
 	req.Header.Add("Content-Type", writer.FormDataContentType())
 	resp := session.MakeRequest(t, req, expectedStatus)
diff --git a/models/twofactor.go b/models/twofactor.go
index 888c910b94..a84da8cdb5 100644
--- a/models/twofactor.go
+++ b/models/twofactor.go
@@ -5,18 +5,14 @@
 package models
 
 import (
-	"crypto/aes"
-	"crypto/cipher"
 	"crypto/md5"
-	"crypto/rand"
 	"crypto/sha256"
 	"crypto/subtle"
 	"encoding/base64"
-	"errors"
 	"fmt"
-	"io"
 
 	"code.gitea.io/gitea/modules/generate"
+	"code.gitea.io/gitea/modules/secret"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 
@@ -67,8 +63,8 @@ func (t *TwoFactor) getEncryptionKey() []byte {
 }
 
 // SetSecret sets the 2FA secret.
-func (t *TwoFactor) SetSecret(secret string) error {
-	secretBytes, err := aesEncrypt(t.getEncryptionKey(), []byte(secret))
+func (t *TwoFactor) SetSecret(secretString string) error {
+	secretBytes, err := secret.AesEncrypt(t.getEncryptionKey(), []byte(secretString))
 	if err != nil {
 		return err
 	}
@@ -82,51 +78,14 @@ func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
 	if err != nil {
 		return false, err
 	}
-	secret, err := aesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
+	secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
 	if err != nil {
 		return false, err
 	}
-	secretStr := string(secret)
+	secretStr := string(secretBytes)
 	return totp.Validate(passcode, secretStr), nil
 }
 
-// aesEncrypt encrypts text and given key with AES.
-func aesEncrypt(key, text []byte) ([]byte, error) {
-	block, err := aes.NewCipher(key)
-	if err != nil {
-		return nil, err
-	}
-	b := base64.StdEncoding.EncodeToString(text)
-	ciphertext := make([]byte, aes.BlockSize+len(b))
-	iv := ciphertext[:aes.BlockSize]
-	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
-		return nil, err
-	}
-	cfb := cipher.NewCFBEncrypter(block, iv)
-	cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
-	return ciphertext, nil
-}
-
-// aesDecrypt decrypts text and given key with AES.
-func aesDecrypt(key, text []byte) ([]byte, error) {
-	block, err := aes.NewCipher(key)
-	if err != nil {
-		return nil, err
-	}
-	if len(text) < aes.BlockSize {
-		return nil, errors.New("ciphertext too short")
-	}
-	iv := text[:aes.BlockSize]
-	text = text[aes.BlockSize:]
-	cfb := cipher.NewCFBDecrypter(block, iv)
-	cfb.XORKeyStream(text, text)
-	data, err := base64.StdEncoding.DecodeString(string(text))
-	if err != nil {
-		return nil, err
-	}
-	return data, nil
-}
-
 // NewTwoFactor creates a new two-factor authentication token.
 func NewTwoFactor(t *TwoFactor) error {
 	_, err := x.Insert(t)
diff --git a/modules/secret/secret.go b/modules/secret/secret.go
index d0e4deacb9..2b6e22cc6c 100644
--- a/modules/secret/secret.go
+++ b/modules/secret/secret.go
@@ -5,8 +5,14 @@
 package secret
 
 import (
+	"crypto/aes"
+	"crypto/cipher"
 	"crypto/rand"
+	"crypto/sha256"
 	"encoding/base64"
+	"encoding/hex"
+	"errors"
+	"io"
 )
 
 // New creats a new secret
@@ -31,3 +37,65 @@ func randomString(len int64) (string, error) {
 	b, err := randomBytes(len)
 	return base64.URLEncoding.EncodeToString(b), err
 }
+
+// AesEncrypt encrypts text and given key with AES.
+func AesEncrypt(key, text []byte) ([]byte, error) {
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return nil, err
+	}
+	b := base64.StdEncoding.EncodeToString(text)
+	ciphertext := make([]byte, aes.BlockSize+len(b))
+	iv := ciphertext[:aes.BlockSize]
+	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
+		return nil, err
+	}
+	cfb := cipher.NewCFBEncrypter(block, iv)
+	cfb.XORKeyStream(ciphertext[aes.BlockSize:], []byte(b))
+	return ciphertext, nil
+}
+
+// AesDecrypt decrypts text and given key with AES.
+func AesDecrypt(key, text []byte) ([]byte, error) {
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return nil, err
+	}
+	if len(text) < aes.BlockSize {
+		return nil, errors.New("ciphertext too short")
+	}
+	iv := text[:aes.BlockSize]
+	text = text[aes.BlockSize:]
+	cfb := cipher.NewCFBDecrypter(block, iv)
+	cfb.XORKeyStream(text, text)
+	data, err := base64.StdEncoding.DecodeString(string(text))
+	if err != nil {
+		return nil, err
+	}
+	return data, nil
+}
+
+// EncryptSecret encrypts a string with given key into a hex string
+func EncryptSecret(key string, str string) (string, error) {
+	keyHash := sha256.Sum256([]byte(key))
+	plaintext := []byte(str)
+	ciphertext, err := AesEncrypt(keyHash[:], plaintext)
+	if err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(ciphertext), nil
+}
+
+// DecryptSecret decrypts a previously encrypted hex string
+func DecryptSecret(key string, cipherhex string) (string, error) {
+	keyHash := sha256.Sum256([]byte(key))
+	ciphertext, err := hex.DecodeString(cipherhex)
+	if err != nil {
+		return "", err
+	}
+	plaintext, err := AesDecrypt(keyHash[:], ciphertext)
+	if err != nil {
+		return "", err
+	}
+	return string(plaintext), nil
+}
diff --git a/modules/secret/secret_test.go b/modules/secret/secret_test.go
index c47201f2d7..6531ffbebc 100644
--- a/modules/secret/secret_test.go
+++ b/modules/secret/secret_test.go
@@ -20,3 +20,16 @@ func TestNew(t *testing.T) {
 	// check if secrets
 	assert.NotEqual(t, result, result2)
 }
+
+func TestEncryptDecrypt(t *testing.T) {
+	var hex string
+	var str string
+
+	hex, _ = EncryptSecret("foo", "baz")
+	str, _ = DecryptSecret("foo", hex)
+	assert.Equal(t, str, "baz")
+
+	hex, _ = EncryptSecret("bar", "baz")
+	str, _ = DecryptSecret("foo", hex)
+	assert.NotEqual(t, str, "baz")
+}
diff --git a/modules/setting/attachment.go b/modules/setting/attachment.go
index 56ccf5bc57..a51b23913a 100644
--- a/modules/setting/attachment.go
+++ b/modules/setting/attachment.go
@@ -6,7 +6,6 @@ package setting
 
 import (
 	"path/filepath"
-	"strings"
 
 	"code.gitea.io/gitea/modules/log"
 )
@@ -65,7 +64,7 @@ func newAttachmentService() {
 		Attachment.Minio.BasePath = sec.Key("MINIO_BASE_PATH").MustString("attachments/")
 	}
 
-	Attachment.AllowedTypes = strings.Replace(sec.Key("ALLOWED_TYPES").MustString("image/jpeg,image/png,application/zip,application/gzip"), "|", ",", -1)
+	Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".docx,.gif,.gz,.jpeg,.jpg,.log,.pdf,.png,.pptx,.txt,.xlsx,.zip")
 	Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(4)
 	Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5)
 	Attachment.Enabled = sec.Key("ENABLED").MustBool(true)
diff --git a/modules/setting/repository.go b/modules/setting/repository.go
index 5203a1bbeb..96159e2f4a 100644
--- a/modules/setting/repository.go
+++ b/modules/setting/repository.go
@@ -58,7 +58,7 @@ var (
 		Upload struct {
 			Enabled      bool
 			TempPath     string
-			AllowedTypes []string `delim:"|"`
+			AllowedTypes string
 			FileMaxSize  int64
 			MaxFiles     int
 		} `ini:"-"`
@@ -85,6 +85,10 @@ var (
 			LockReasons []string
 		} `ini:"repository.issue"`
 
+		Release struct {
+			AllowedTypes string
+		} `ini:"repository.release"`
+
 		Signing struct {
 			SigningKey        string
 			SigningName       string
@@ -165,13 +169,13 @@ var (
 		Upload: struct {
 			Enabled      bool
 			TempPath     string
-			AllowedTypes []string `delim:"|"`
+			AllowedTypes string
 			FileMaxSize  int64
 			MaxFiles     int
 		}{
 			Enabled:      true,
 			TempPath:     "data/tmp/uploads",
-			AllowedTypes: []string{},
+			AllowedTypes: "",
 			FileMaxSize:  3,
 			MaxFiles:     5,
 		},
@@ -213,6 +217,12 @@ var (
 			LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
 		},
 
+		Release: struct {
+			AllowedTypes string
+		}{
+			AllowedTypes: "",
+		},
+
 		// Signing settings
 		Signing: struct {
 			SigningKey        string
diff --git a/modules/upload/filetype.go b/modules/upload/filetype.go
deleted file mode 100644
index 2ab326d116..0000000000
--- a/modules/upload/filetype.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2019 The Gitea Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package upload
-
-import (
-	"fmt"
-	"net/http"
-	"strings"
-
-	"code.gitea.io/gitea/modules/log"
-)
-
-// ErrFileTypeForbidden not allowed file type error
-type ErrFileTypeForbidden struct {
-	Type string
-}
-
-// IsErrFileTypeForbidden checks if an error is a ErrFileTypeForbidden.
-func IsErrFileTypeForbidden(err error) bool {
-	_, ok := err.(ErrFileTypeForbidden)
-	return ok
-}
-
-func (err ErrFileTypeForbidden) Error() string {
-	return fmt.Sprintf("File type is not allowed: %s", err.Type)
-}
-
-// VerifyAllowedContentType validates a file is allowed to be uploaded.
-func VerifyAllowedContentType(buf []byte, allowedTypes []string) error {
-	fileType := http.DetectContentType(buf)
-
-	for _, t := range allowedTypes {
-		t := strings.Trim(t, " ")
-
-		if t == "*/*" || t == fileType ||
-			// Allow directives after type, like 'text/plain; charset=utf-8'
-			strings.HasPrefix(fileType, t+";") {
-			return nil
-		}
-	}
-
-	log.Info("Attachment with type %s blocked from upload", fileType)
-	return ErrFileTypeForbidden{Type: fileType}
-}
diff --git a/modules/upload/filetype_test.go b/modules/upload/filetype_test.go
deleted file mode 100644
index f93a1c5cc3..0000000000
--- a/modules/upload/filetype_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright 2019 The Gitea Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package upload
-
-import (
-	"bytes"
-	"compress/gzip"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestUpload(t *testing.T) {
-	testContent := []byte(`This is a plain text file.`)
-	var b bytes.Buffer
-	w := gzip.NewWriter(&b)
-	w.Write(testContent)
-	w.Close()
-
-	kases := []struct {
-		data         []byte
-		allowedTypes []string
-		err          error
-	}{
-		{
-			data:         testContent,
-			allowedTypes: []string{"text/plain"},
-			err:          nil,
-		},
-		{
-			data:         testContent,
-			allowedTypes: []string{"application/x-gzip"},
-			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
-		},
-		{
-			data:         b.Bytes(),
-			allowedTypes: []string{"application/x-gzip"},
-			err:          nil,
-		},
-	}
-
-	for _, kase := range kases {
-		assert.Equal(t, kase.err, VerifyAllowedContentType(kase.data, kase.allowedTypes))
-	}
-}
diff --git a/modules/upload/upload.go b/modules/upload/upload.go
new file mode 100644
index 0000000000..e020faca7e
--- /dev/null
+++ b/modules/upload/upload.go
@@ -0,0 +1,94 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package upload
+
+import (
+	"net/http"
+	"path"
+	"regexp"
+	"strings"
+
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+// ErrFileTypeForbidden not allowed file type error
+type ErrFileTypeForbidden struct {
+	Type string
+}
+
+// IsErrFileTypeForbidden checks if an error is a ErrFileTypeForbidden.
+func IsErrFileTypeForbidden(err error) bool {
+	_, ok := err.(ErrFileTypeForbidden)
+	return ok
+}
+
+func (err ErrFileTypeForbidden) Error() string {
+	return "This file extension or type is not allowed to be uploaded."
+}
+
+var mimeTypeSuffixRe = regexp.MustCompile(`;.*$`)
+var wildcardTypeRe = regexp.MustCompile(`^[a-z]+/\*$`)
+
+// Verify validates whether a file is allowed to be uploaded.
+func Verify(buf []byte, fileName string, allowedTypesStr string) error {
+	allowedTypesStr = strings.ReplaceAll(allowedTypesStr, "|", ",") // compat for old config format
+
+	allowedTypes := []string{}
+	for _, entry := range strings.Split(allowedTypesStr, ",") {
+		entry = strings.ToLower(strings.TrimSpace(entry))
+		if entry != "" {
+			allowedTypes = append(allowedTypes, entry)
+		}
+	}
+
+	if len(allowedTypes) == 0 {
+		return nil // everything is allowed
+	}
+
+	fullMimeType := http.DetectContentType(buf)
+	mimeType := strings.TrimSpace(mimeTypeSuffixRe.ReplaceAllString(fullMimeType, ""))
+	extension := strings.ToLower(path.Ext(fileName))
+
+	// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
+	for _, allowEntry := range allowedTypes {
+		if allowEntry == "*/*" {
+			return nil // everything allowed
+		} else if strings.HasPrefix(allowEntry, ".") && allowEntry == extension {
+			return nil // extension is allowed
+		} else if mimeType == allowEntry {
+			return nil // mime type is allowed
+		} else if wildcardTypeRe.MatchString(allowEntry) && strings.HasPrefix(mimeType, allowEntry[:len(allowEntry)-1]) {
+			return nil // wildcard match, e.g. image/*
+		}
+	}
+
+	log.Info("Attachment with type %s blocked from upload", fullMimeType)
+	return ErrFileTypeForbidden{Type: fullMimeType}
+}
+
+// AddUploadContext renders template values for dropzone
+func AddUploadContext(ctx *context.Context, uploadType string) {
+	if uploadType == "release" {
+		ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments"
+		ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/releases/attachments/remove"
+		ctx.Data["UploadAccepts"] = strings.Replace(setting.Repository.Release.AllowedTypes, "|", ",", -1)
+		ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
+		ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
+	} else if uploadType == "comment" {
+		ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments"
+		ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove"
+		ctx.Data["UploadAccepts"] = strings.Replace(setting.Attachment.AllowedTypes, "|", ",", -1)
+		ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
+		ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
+	} else if uploadType == "repo" {
+		ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file"
+		ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove"
+		ctx.Data["UploadAccepts"] = strings.Replace(setting.Repository.Upload.AllowedTypes, "|", ",", -1)
+		ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
+		ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
+	}
+}
diff --git a/modules/upload/upload_test.go b/modules/upload/upload_test.go
new file mode 100644
index 0000000000..d258b04f77
--- /dev/null
+++ b/modules/upload/upload_test.go
@@ -0,0 +1,195 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package upload
+
+import (
+	"bytes"
+	"compress/gzip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUpload(t *testing.T) {
+	testContent := []byte(`This is a plain text file.`)
+	var b bytes.Buffer
+	w := gzip.NewWriter(&b)
+	w.Write(testContent)
+	w.Close()
+
+	kases := []struct {
+		data         []byte
+		fileName     string
+		allowedTypes string
+		err          error
+	}{
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "dir/test.txt",
+			allowedTypes: "",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "../../../test.txt",
+			allowedTypes: "",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: ",",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "|",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "*/*",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "*/*,",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "*/*|",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "text/plain",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "dir/test.txt",
+			allowedTypes: "text/plain",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "/dir.txt/test.js",
+			allowedTypes: ".js",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: " text/plain ",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: ".txt",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: " .txt,.js",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: " .txt|.js",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "../../test.txt",
+			allowedTypes: " .txt|.js",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: " .txt ,.js ",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "text/plain, .txt",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "text/*",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "text/*,.js",
+			err:          nil,
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "text/**",
+			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: "application/x-gzip",
+			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: ".zip",
+			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: ".zip,.txtx",
+			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
+		},
+		{
+			data:         testContent,
+			fileName:     "test.txt",
+			allowedTypes: ".zip|.txtx",
+			err:          ErrFileTypeForbidden{"text/plain; charset=utf-8"},
+		},
+		{
+			data:         b.Bytes(),
+			fileName:     "test.txt",
+			allowedTypes: "application/x-gzip",
+			err:          nil,
+		},
+	}
+
+	for _, kase := range kases {
+		assert.Equal(t, kase.err, Verify(kase.data, kase.fileName, kase.allowedTypes))
+	}
+}
diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go
index 3d1084f211..f352c10829 100644
--- a/routers/api/v1/repo/release_attachment.go
+++ b/routers/api/v1/repo/release_attachment.go
@@ -6,7 +6,6 @@ package repo
 
 import (
 	"net/http"
-	"strings"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/context"
@@ -182,7 +181,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
 	}
 
 	// Check if the filetype is allowed by the settings
-	err = upload.VerifyAllowedContentType(buf, strings.Split(setting.Attachment.AllowedTypes, ","))
+	err = upload.Verify(buf, header.Filename, setting.Repository.Release.AllowedTypes)
 	if err != nil {
 		ctx.Error(http.StatusBadRequest, "DetectContentType", err)
 		return
diff --git a/routers/repo/attachment.go b/routers/repo/attachment.go
index 313704bc38..5b699abc8d 100644
--- a/routers/repo/attachment.go
+++ b/routers/repo/attachment.go
@@ -7,7 +7,6 @@ package repo
 import (
 	"fmt"
 	"net/http"
-	"strings"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/context"
@@ -17,16 +16,18 @@ import (
 	"code.gitea.io/gitea/modules/upload"
 )
 
-func renderAttachmentSettings(ctx *context.Context) {
-	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
-	ctx.Data["AttachmentStoreType"] = setting.Attachment.Storage.Type
-	ctx.Data["AttachmentAllowedTypes"] = setting.Attachment.AllowedTypes
-	ctx.Data["AttachmentMaxSize"] = setting.Attachment.MaxSize
-	ctx.Data["AttachmentMaxFiles"] = setting.Attachment.MaxFiles
+// UploadIssueAttachment response for Issue/PR attachments
+func UploadIssueAttachment(ctx *context.Context) {
+	uploadAttachment(ctx, setting.Attachment.AllowedTypes)
 }
 
-// UploadAttachment response for uploading issue's attachment
-func UploadAttachment(ctx *context.Context) {
+// UploadReleaseAttachment response for uploading release attachments
+func UploadReleaseAttachment(ctx *context.Context) {
+	uploadAttachment(ctx, setting.Repository.Release.AllowedTypes)
+}
+
+// UploadAttachment response for uploading attachments
+func uploadAttachment(ctx *context.Context, allowedTypes string) {
 	if !setting.Attachment.Enabled {
 		ctx.Error(404, "attachment is not enabled")
 		return
@@ -45,7 +46,7 @@ func UploadAttachment(ctx *context.Context) {
 		buf = buf[:n]
 	}
 
-	err = upload.VerifyAllowedContentType(buf, strings.Split(setting.Attachment.AllowedTypes, ","))
+	err = upload.Verify(buf, header.Filename, allowedTypes)
 	if err != nil {
 		ctx.Error(400, err.Error())
 		return
diff --git a/routers/repo/compare.go b/routers/repo/compare.go
index 9329b5a1d2..fb6076cbe1 100644
--- a/routers/repo/compare.go
+++ b/routers/repo/compare.go
@@ -17,6 +17,7 @@ import (
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/upload"
 	"code.gitea.io/gitea/services/gitdiff"
 )
 
@@ -578,7 +579,8 @@ func CompareDiff(ctx *context.Context) {
 	ctx.Data["RequireSimpleMDE"] = true
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
 	setTemplateIfExists(ctx, pullRequestTemplateKey, nil, pullRequestTemplateCandidates)
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
 
 	ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(models.UnitTypePullRequests)
 
diff --git a/routers/repo/editor.go b/routers/repo/editor.go
index 6a3f379f6a..aa10bd146a 100644
--- a/routers/repo/editor.go
+++ b/routers/repo/editor.go
@@ -494,18 +494,12 @@ func DeleteFilePost(ctx *context.Context, form auth.DeleteRepoFileForm) {
 	}
 }
 
-func renderUploadSettings(ctx *context.Context) {
-	ctx.Data["RequireTribute"] = true
-	ctx.Data["RequireSimpleMDE"] = true
-	ctx.Data["UploadAllowedTypes"] = strings.Join(setting.Repository.Upload.AllowedTypes, ",")
-	ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
-	ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
-}
-
 // UploadFile render upload file page
 func UploadFile(ctx *context.Context) {
 	ctx.Data["PageIsUpload"] = true
-	renderUploadSettings(ctx)
+	ctx.Data["RequireTribute"] = true
+	ctx.Data["RequireSimpleMDE"] = true
+	upload.AddUploadContext(ctx, "repo")
 	canCommit := renderCommitRights(ctx)
 	treePath := cleanUploadFileName(ctx.Repo.TreePath)
 	if treePath != ctx.Repo.TreePath {
@@ -538,7 +532,9 @@ func UploadFile(ctx *context.Context) {
 // UploadFilePost response for uploading file
 func UploadFilePost(ctx *context.Context, form auth.UploadRepoFileForm) {
 	ctx.Data["PageIsUpload"] = true
-	renderUploadSettings(ctx)
+	ctx.Data["RequireTribute"] = true
+	ctx.Data["RequireSimpleMDE"] = true
+	upload.AddUploadContext(ctx, "repo")
 	canCommit := renderCommitRights(ctx)
 
 	oldBranchName := ctx.Repo.BranchName
@@ -704,12 +700,10 @@ func UploadFileToServer(ctx *context.Context) {
 		buf = buf[:n]
 	}
 
-	if len(setting.Repository.Upload.AllowedTypes) > 0 {
-		err = upload.VerifyAllowedContentType(buf, setting.Repository.Upload.AllowedTypes)
-		if err != nil {
-			ctx.Error(400, err.Error())
-			return
-		}
+	err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes)
+	if err != nil {
+		ctx.Error(400, err.Error())
+		return
 	}
 
 	name := cleanUploadFileName(header.Filename)
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index be46ddbeb9..f44e88fc4b 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -26,6 +26,7 @@ import (
 	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/upload"
 	"code.gitea.io/gitea/modules/util"
 	comment_service "code.gitea.io/gitea/services/comments"
 	issue_service "code.gitea.io/gitea/services/issue"
@@ -573,6 +574,8 @@ func NewIssue(ctx *context.Context) {
 	body := ctx.Query("body")
 	ctx.Data["BodyQuery"] = body
 	ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
 
 	milestoneID := ctx.QueryInt64("milestone")
 	if milestoneID > 0 {
@@ -599,8 +602,6 @@ func NewIssue(ctx *context.Context) {
 
 	}
 
-	renderAttachmentSettings(ctx)
-
 	RetrieveRepoMetas(ctx, ctx.Repo.Repository, false)
 	setTemplateIfExists(ctx, issueTemplateKey, context.IssueTemplateDirCandidates, IssueTemplateCandidates)
 	if ctx.Written() {
@@ -731,7 +732,8 @@ func NewIssuePost(ctx *context.Context, form auth.CreateIssueForm) {
 	ctx.Data["RequireSimpleMDE"] = true
 	ctx.Data["ReadOnly"] = false
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
 
 	var (
 		repo        = ctx.Repo.Repository
@@ -880,8 +882,8 @@ func ViewIssue(ctx *context.Context) {
 	ctx.Data["RequireTribute"] = true
 	ctx.Data["RequireSimpleMDE"] = true
 	ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(models.UnitTypeProjects)
-
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
 
 	if err = issue.LoadAttributes(); err != nil {
 		ctx.ServerError("LoadAttributes", err)
diff --git a/routers/repo/pull.go b/routers/repo/pull.go
index a6f7a70744..535bd0cdb5 100644
--- a/routers/repo/pull.go
+++ b/routers/repo/pull.go
@@ -24,6 +24,7 @@ import (
 	"code.gitea.io/gitea/modules/notification"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/modules/upload"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/routers/utils"
 	"code.gitea.io/gitea/services/gitdiff"
@@ -892,7 +893,8 @@ func CompareAndPullRequestPost(ctx *context.Context, form auth.CreateIssueForm)
 	ctx.Data["IsDiffCompare"] = true
 	ctx.Data["RequireHighlightJS"] = true
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
 
 	var (
 		repo        = ctx.Repo.Repository
diff --git a/routers/repo/release.go b/routers/repo/release.go
index 8cd46e850d..ab251ec755 100644
--- a/routers/repo/release.go
+++ b/routers/repo/release.go
@@ -16,6 +16,7 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/upload"
 	releaseservice "code.gitea.io/gitea/services/release"
 )
 
@@ -192,7 +193,8 @@ func NewRelease(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
 	ctx.Data["PageIsReleaseList"] = true
 	ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "release")
 	ctx.HTML(200, tplReleaseNew)
 }
 
@@ -278,7 +280,8 @@ func EditRelease(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
 	ctx.Data["PageIsReleaseList"] = true
 	ctx.Data["PageIsEditRelease"] = true
-	renderAttachmentSettings(ctx)
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "release")
 
 	tagName := ctx.Params("*")
 	rel, err := models.GetRelease(ctx.Repo.Repository.ID, tagName)
diff --git a/routers/routes/routes.go b/routers/routes/routes.go
index f60af5dad0..97f4e5aeaf 100644
--- a/routers/routes/routes.go
+++ b/routers/routes/routes.go
@@ -512,11 +512,6 @@ func RegisterRoutes(m *macaron.Macaron) {
 		m.Get("/attachments/:uuid", repo.GetAttachment)
 	}, ignSignIn)
 
-	m.Group("/attachments", func() {
-		m.Post("", repo.UploadAttachment)
-		m.Post("/delete", repo.DeleteAttachment)
-	}, reqSignIn)
-
 	m.Group("/:username", func() {
 		m.Post("/action/:action", user.Action)
 	}, reqSignIn)
@@ -754,8 +749,11 @@ func RegisterRoutes(m *macaron.Macaron) {
 				m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction)
 				m.Post("/lock", reqRepoIssueWriter, bindIgnErr(auth.IssueLockForm{}), repo.LockIssue)
 				m.Post("/unlock", reqRepoIssueWriter, repo.UnlockIssue)
-				m.Get("/attachments", repo.GetIssueAttachments)
 			}, context.RepoMustNotBeArchived())
+			m.Group("/:index", func() {
+				m.Get("/attachments", repo.GetIssueAttachments)
+				m.Get("/attachments/:uuid", repo.GetAttachment)
+			})
 
 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
 			m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
@@ -764,13 +762,17 @@ func RegisterRoutes(m *macaron.Macaron) {
 			m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest)
 			m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
 			m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
+			m.Post("/attachments", repo.UploadIssueAttachment)
+			m.Post("/attachments/remove", repo.DeleteAttachment)
 		}, context.RepoMustNotBeArchived())
 		m.Group("/comments/:id", func() {
 			m.Post("", repo.UpdateCommentContent)
 			m.Post("/delete", repo.DeleteComment)
 			m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction)
-			m.Get("/attachments", repo.GetCommentAttachments)
 		}, context.RepoMustNotBeArchived())
+		m.Group("/comments/:id", func() {
+			m.Get("/attachments", repo.GetCommentAttachments)
+		})
 		m.Group("/labels", func() {
 			m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel)
 			m.Post("/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel)
@@ -826,11 +828,14 @@ func RegisterRoutes(m *macaron.Macaron) {
 			m.Get("/", repo.Releases)
 			m.Get("/tag/*", repo.SingleRelease)
 			m.Get("/latest", repo.LatestRelease)
+			m.Get("/attachments/:uuid", repo.GetAttachment)
 		}, repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag))
 		m.Group("/releases", func() {
 			m.Get("/new", repo.NewRelease)
 			m.Post("/new", bindIgnErr(auth.NewReleaseForm{}), repo.NewReleasePost)
 			m.Post("/delete", repo.DeleteRelease)
+			m.Post("/attachments", repo.UploadReleaseAttachment)
+			m.Post("/attachments/remove", repo.DeleteAttachment)
 		}, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef())
 		m.Group("/releases", func() {
 			m.Get("/edit/*", repo.EditRelease)
diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl
index 8138194e1b..6ad7c7445f 100644
--- a/templates/repo/editor/upload.tmpl
+++ b/templates/repo/editor/upload.tmpl
@@ -27,7 +27,7 @@
 			</div>
 			<div class="field">
 				<div class="files"></div>
-				<div class="ui dropzone" id="dropzone" data-upload-url="{{.RepoLink}}/upload-file" data-remove-url="{{.RepoLink}}/upload-remove" data-csrf="{{.CsrfToken}}" data-accepts="{{.UploadAllowedTypes}}" data-max-file="{{.UploadMaxFiles}}" data-max-size="{{.UploadMaxSize}}" data-default-message="{{.i18n.Tr "dropzone.default_message"}}" data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}" data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}" data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"></div>
+				{{template "repo/upload" .}}
 			</div>
 			{{template "repo/editor/commit_form" .}}
 		</form>
diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl
index 77537edf73..d24f7a22ad 100644
--- a/templates/repo/issue/comment_tab.tmpl
+++ b/templates/repo/issue/comment_tab.tmpl
@@ -12,8 +12,8 @@
 	</div>
 </div>
 {{if .IsAttachmentEnabled}}
-<div class="field">
-	<div class="files"></div>
-	<div class="ui dropzone" id="dropzone" data-upload-url="{{AppSubUrl}}/attachments" data-accepts="{{.AttachmentAllowedTypes}}" data-max-file="{{.AttachmentMaxFiles}}" data-max-size="{{.AttachmentMaxSize}}" data-default-message="{{.i18n.Tr "dropzone.default_message"}}" data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}" data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}" data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"></div>
-</div>
+	<div class="field">
+		<div class="files"></div>
+		{{template "repo/upload" .}}
+	</div>
 {{end}}
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index 1addbaf5bb..a4ce0a7106 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -197,19 +197,10 @@
 			</div>
 		</div>
 		{{if .IsAttachmentEnabled}}
-		<div class="field">
-			<div class="comment-files"></div>
-			<div class="ui dropzone" id="comment-dropzone"
-				data-upload-url="{{AppSubUrl}}/attachments"
-				data-remove-url="{{AppSubUrl}}/attachments/delete"
-				data-csrf="{{.CsrfToken}}" data-accepts="{{.AttachmentAllowedTypes}}"
-				data-max-file="{{.AttachmentMaxFiles}}" data-max-size="{{.AttachmentMaxSize}}"
-				data-default-message="{{.i18n.Tr "dropzone.default_message"}}"
-				data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}"
-				data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}"
-				data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}">
+			<div class="field">
+				<div class="comment-files"></div>
+				{{template "repo/upload" .}}
 			</div>
-		</div>
 		{{end}}
 		<div class="field footer">
 			<div class="text right edit">
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl
index 7b9a063564..8d94970bf6 100644
--- a/templates/repo/release/new.tmpl
+++ b/templates/repo/release/new.tmpl
@@ -49,10 +49,10 @@
 					<textarea name="content">{{.content}}</textarea>
 				</div>
 				{{if .IsAttachmentEnabled}}
-				<div class="field">
-					<div class="files"></div>
-					<div class="ui dropzone" id="dropzone" data-upload-url="{{AppSubUrl}}/attachments" data-accepts="{{.AttachmentAllowedTypes}}" data-max-file="{{.AttachmentMaxFiles}}" data-max-size="{{.AttachmentMaxSize}}" data-default-message="{{.i18n.Tr "dropzone.default_message"}}" data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}" data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}" data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"></div>
-				</div>
+					<div class="field">
+						<div class="files"></div>
+						{{template "repo/upload" .}}
+					</div>
 				{{end}}
 			</div>
 			<div class="ui container">
diff --git a/templates/repo/upload.tmpl b/templates/repo/upload.tmpl
new file mode 100644
index 0000000000..93bc098dae
--- /dev/null
+++ b/templates/repo/upload.tmpl
@@ -0,0 +1,13 @@
+<div
+	class="ui dropzone"
+	id="dropzone"
+	data-upload-url="{{.UploadUrl}}"
+	data-remove-url="{{.UploadRemoveUrl}}"
+	data-accepts="{{.UploadAccepts}}"
+	data-max-file="{{.UploadMaxFiles}}"
+	data-max-size="{{.UploadMaxSize}}"
+	data-default-message="{{.i18n.Tr "dropzone.default_message"}}"
+	data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}"
+	data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}"
+	data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"
+></div>
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 415db385b3..e4f1575391 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -326,7 +326,7 @@ function uploadFile(file, callback) {
     }
   });
 
-  xhr.open('post', `${AppSubUrl}/attachments`, true);
+  xhr.open('post', $('#dropzone').data('upload-url'), true);
   xhr.setRequestHeader('X-Csrf-Token', csrf);
   const formData = new FormData();
   formData.append('file', file, file.name);
@@ -902,7 +902,7 @@ async function initRepository() {
             headers: {'X-Csrf-Token': csrf},
             maxFiles: $dropzone.data('max-file'),
             maxFilesize: $dropzone.data('max-size'),
-            acceptedFiles: ($dropzone.data('accepts') === '*/*') ? null : $dropzone.data('accepts'),
+            acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
             addRemoveLinks: true,
             dictDefaultMessage: $dropzone.data('default-message'),
             dictInvalidFileType: $dropzone.data('invalid-input-type'),
@@ -923,10 +923,10 @@ async function initRepository() {
                   return;
                 }
                 $(`#${filenameDict[file.name].uuid}`).remove();
-                if ($dropzone.data('remove-url') && $dropzone.data('csrf') && !filenameDict[file.name].submitted) {
+                if ($dropzone.data('remove-url') && !filenameDict[file.name].submitted) {
                   $.post($dropzone.data('remove-url'), {
                     file: filenameDict[file.name].uuid,
-                    _csrf: $dropzone.data('csrf')
+                    _csrf: csrf,
                   });
                 }
               });
@@ -2323,7 +2323,7 @@ $(document).ready(async () => {
       headers: {'X-Csrf-Token': csrf},
       maxFiles: $dropzone.data('max-file'),
       maxFilesize: $dropzone.data('max-size'),
-      acceptedFiles: ($dropzone.data('accepts') === '*/*') ? null : $dropzone.data('accepts'),
+      acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
       addRemoveLinks: true,
       dictDefaultMessage: $dropzone.data('default-message'),
       dictInvalidFileType: $dropzone.data('invalid-input-type'),
@@ -2340,10 +2340,10 @@ $(document).ready(async () => {
           if (file.name in filenameDict) {
             $(`#${filenameDict[file.name]}`).remove();
           }
-          if ($dropzone.data('remove-url') && $dropzone.data('csrf')) {
+          if ($dropzone.data('remove-url')) {
             $.post($dropzone.data('remove-url'), {
               file: filenameDict[file.name],
-              _csrf: $dropzone.data('csrf')
+              _csrf: csrf
             });
           }
         });