diff --git a/cmd/config.go b/cmd/config.go
index de55c28e50..3d3f425583 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -145,7 +145,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
 	fmt.Fprintf(w, "Sign up:\t%t\n", set.Signup)
 	fmt.Fprintf(w, "Create User Dir:\t%t\n", set.CreateUserDir)
 	fmt.Fprintf(w, "Auth method:\t%s\n", set.AuthMethod)
-	fmt.Fprintf(w, "Shell:\t%s\t\n", strings.Join(set.Shell, " "))
+	fmt.Fprintf(w, "Shell:\t%s\t\n", set.Shell)
 	fmt.Fprintln(w, "\nBranding:")
 	fmt.Fprintf(w, "\tName:\t%s\n", set.Branding.Name)
 	fmt.Fprintf(w, "\tFiles override:\t%s\n", set.Branding.Files)
diff --git a/cmd/config_init.go b/cmd/config_init.go
index 60a0f37b94..64934e2f5f 100644
--- a/cmd/config_init.go
+++ b/cmd/config_init.go
@@ -32,7 +32,7 @@ override the options.`,
 			Key:           generateKey(),
 			Signup:        mustGetBool(flags, "signup"),
 			CreateUserDir: mustGetBool(flags, "create-user-dir"),
-			Shell:         convertCmdStrToCmdArray(mustGetString(flags, "shell")),
+			Shell:         mustGetString(flags, "shell"),
 			AuthMethod:    authMethod,
 			Defaults:      defaults,
 			Branding: settings.Branding{
diff --git a/cmd/config_set.go b/cmd/config_set.go
index 23ff7e1bb2..40d228c133 100644
--- a/cmd/config_set.go
+++ b/cmd/config_set.go
@@ -48,7 +48,7 @@ you want to change. Other options will remain unchanged.`,
 			case "auth.method":
 				hasAuth = true
 			case "shell":
-				set.Shell = convertCmdStrToCmdArray(mustGetString(flags, flag.Name))
+				set.Shell = mustGetString(flags, flag.Name)
 			case "create-user-dir":
 				set.CreateUserDir = mustGetBool(flags, flag.Name)
 			case "branding.name":
diff --git a/cmd/root.go b/cmd/root.go
index 59329c5cfe..b484212573 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -344,7 +344,7 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) {
 			RetryCount: settings.DefaultTusRetryCount,
 		},
 		Commands: nil,
-		Shell:    nil,
+		Shell:    "",
 		Rules:    nil,
 	}
 
diff --git a/cmd/utils.go b/cmd/utils.go
index 78f48d1307..b3b0374299 100644
--- a/cmd/utils.go
+++ b/cmd/utils.go
@@ -186,15 +186,3 @@ func cleanUpMapValue(v interface{}) interface{} {
 		return v
 	}
 }
-
-// convertCmdStrToCmdArray checks if cmd string is blank (whitespace included)
-// then returns empty string array, else returns the split word array of cmd.
-// This is to ensure the result will never be []string{""}
-func convertCmdStrToCmdArray(cmd string) []string {
-	var cmdArray []string
-	trimmedCmdStr := strings.TrimSpace(cmd)
-	if trimmedCmdStr != "" {
-		cmdArray = strings.Split(trimmedCmdStr, " ")
-	}
-	return cmdArray
-}
diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts
index a2c19f7632..d16c7b3515 100644
--- a/frontend/src/types/settings.d.ts
+++ b/frontend/src/types/settings.d.ts
@@ -6,7 +6,7 @@ interface ISettings {
   rules: any[];
   branding: SettingsBranding;
   tus: SettingsTus;
-  shell: string[];
+  shell: string;
   commands: SettingsCommand;
 }
 
diff --git a/frontend/src/views/settings/Global.vue b/frontend/src/views/settings/Global.vue
index 5bbaec7f4d..df6c6b1e93 100644
--- a/frontend/src/views/settings/Global.vue
+++ b/frontend/src/views/settings/Global.vue
@@ -296,12 +296,7 @@ const save = async () => {
   if (settings.value === null) return false;
   const newSettings: ISettings = {
     ...settings.value,
-    shell:
-      settings.value?.shell
-        .join(" ")
-        .trim()
-        .split(" ")
-        .filter((s: string) => s !== "") ?? [],
+    shell: settings.value?.shell?.trim() ?? "", // Change this to a string
     commands: {},
   };
 
@@ -309,7 +304,6 @@ const save = async () => {
     keyof SettingsCommand
   >;
   for (const key of keys) {
-    // not sure if we can safely assume non-null
     const newValue = commandObject.value[key];
     if (!newValue) continue;
 
@@ -321,7 +315,9 @@ const save = async () => {
         .filter((cmd: string) => cmd !== "");
     }
   }
-  newSettings.shell = shellValue.value.split("\n");
+
+  // Update this line to assign the string directly, without splitting
+  newSettings.shell = shellValue.value.trim();
 
   if (newSettings.branding.theme !== getTheme()) {
     setTheme(newSettings.branding.theme);
@@ -386,7 +382,7 @@ onMounted(async () => {
 
     originalSettings.value = original;
     settings.value = newSettings;
-    shellValue.value = newSettings.shell.join("\n");
+    shellValue.value = newSettings.shell;
   } catch (err) {
     if (err instanceof Error) {
       error.value = err;
diff --git a/http/settings.go b/http/settings.go
index de3f22adc8..81f3eee21f 100644
--- a/http/settings.go
+++ b/http/settings.go
@@ -16,7 +16,7 @@ type settingsData struct {
 	Rules            []rules.Rule          `json:"rules"`
 	Branding         settings.Branding     `json:"branding"`
 	Tus              settings.Tus          `json:"tus"`
-	Shell            []string              `json:"shell"`
+	Shell            string                `json:"shell"`
 	Commands         map[string][]string   `json:"commands"`
 }
 
diff --git a/http/tus_handlers.go b/http/tus_handlers.go
index 7a3254ae72..f9d0bbbd0d 100644
--- a/http/tus_handlers.go
+++ b/http/tus_handlers.go
@@ -131,6 +131,13 @@ func tusPatchHandler() handleFunc {
 			)
 		}
 
+		if uploadOffset == 0 {
+			err = d.RunBeforeHook("upload", r.URL.Path, "", d.user)
+			if err != nil {
+				return errToStatus(err), err
+			}
+		}
+
 		openFile, err := d.user.Fs.OpenFile(r.URL.Path, os.O_WRONLY|os.O_APPEND, files.PermFile)
 		if err != nil {
 			return http.StatusInternalServerError, fmt.Errorf("could not open file: %w", err)
@@ -150,6 +157,13 @@ func tusPatchHandler() handleFunc {
 
 		w.Header().Set("Upload-Offset", strconv.FormatInt(uploadOffset+bytesWritten, 10))
 
+		if uint64(bytesWritten) < (d.Settings.Tus.ChunkSize) {
+			err = d.RunAfterHook("upload", r.URL.Path, "", d.user)
+			if err != nil {
+				return errToStatus(err), err
+			}
+		}
+
 		return http.StatusNoContent, nil
 	})
 }
diff --git a/runner/parser.go b/runner/parser.go
index 6fd64a4b64..a8ae934288 100644
--- a/runner/parser.go
+++ b/runner/parser.go
@@ -8,11 +8,11 @@ import (
 
 // ParseCommand parses the command taking in account if the current
 // instance uses a shell to run the commands or just calls the binary
-// directyly.
+// directly.
 func ParseCommand(s *settings.Settings, raw string) ([]string, error) {
 	var command []string
 
-	if len(s.Shell) == 0 || s.Shell[0] == "" {
+	if s.Shell == "" {
 		cmd, args, err := SplitCommandAndArgs(raw)
 		if err != nil {
 			return nil, err
@@ -26,7 +26,19 @@ func ParseCommand(s *settings.Settings, raw string) ([]string, error) {
 		command = append(command, cmd)
 		command = append(command, args...)
 	} else {
-		command = append(s.Shell, raw) //nolint:gocritic
+		cmd, args, err := SplitCommandAndArgs(s.Shell)
+		if err != nil {
+			return nil, err
+		}
+
+		_, err = exec.LookPath(cmd)
+		if err != nil {
+			return nil, err
+		}
+
+		command = append(command, cmd)
+		command = append(command, args...)
+		command = append(command, raw)
 	}
 
 	return command, nil
diff --git a/runner/runner.go b/runner/runner.go
index 2dbafa5cd1..829e591020 100644
--- a/runner/runner.go
+++ b/runner/runner.go
@@ -17,6 +17,39 @@ type Runner struct {
 	*settings.Settings
 }
 
+// RunBeforeHook and RunAfterHook trigger the command runner at their respective times.
+func (r *Runner) RunBeforeHook(evt, path, dst string, user *users.User) error {
+	path = user.FullPath(path)
+	dst = user.FullPath(dst)
+	if r.Enabled {
+		if val, ok := r.Commands["before_"+evt]; ok {
+			for _, command := range val {
+				err := r.exec(command, "before_"+evt, path, dst, user)
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
+func (r *Runner) RunAfterHook(evt, path, dst string, user *users.User) error {
+	path = user.FullPath(path)
+	dst = user.FullPath(dst)
+	if r.Enabled {
+		if val, ok := r.Commands["after_"+evt]; ok {
+			for _, command := range val {
+				err := r.exec(command, "after_"+evt, path, dst, user)
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
 // RunHook runs the hooks for the before and after event.
 func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.User) error {
 	path = user.FullPath(path)
diff --git a/settings/settings.go b/settings/settings.go
index 2290839667..27cdff58c0 100644
--- a/settings/settings.go
+++ b/settings/settings.go
@@ -2,6 +2,8 @@ package settings
 
 import (
 	"crypto/rand"
+	"encoding/json"
+	"fmt"
 	"log"
 	"strings"
 	"time"
@@ -25,7 +27,7 @@ type Settings struct {
 	Branding         Branding            `json:"branding"`
 	Tus              Tus                 `json:"tus"`
 	Commands         map[string][]string `json:"commands"`
-	Shell            []string            `json:"shell"`
+	Shell            string              `json:"shell"`
 	Rules            []rules.Rule        `json:"rules"`
 }
 
@@ -81,3 +83,40 @@ func GenerateKey() ([]byte, error) {
 
 	return b, nil
 }
+
+// UnmarshalJSON implements custom JSON unmarshaling for Settings
+func (s *Settings) UnmarshalJSON(data []byte) error {
+
+	type Alias Settings
+	aux := &struct {
+		Shell interface{} `json:"shell"`
+		*Alias
+	}{
+		Alias: (*Alias)(s),
+	}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	// Handle the Shell field conversion
+	switch v := aux.Shell.(type) {
+	case string:
+		s.Shell = v
+	case []interface{}:
+		// Convert array to string by joining elements
+		var parts []string
+		for _, item := range v {
+			if str, ok := item.(string); ok {
+				parts = append(parts, str)
+			}
+		}
+		s.Shell = strings.Join(parts, " ")
+	case nil:
+		s.Shell = ""
+	default:
+		return fmt.Errorf("invalid type for shell field: %T", v)
+	}
+
+	return nil
+}
diff --git a/settings/storage.go b/settings/storage.go
index a006a84b36..3199715ade 100644
--- a/settings/storage.go
+++ b/settings/storage.go
@@ -72,8 +72,8 @@ func (s *Storage) Save(set *Settings) error {
 		set.Rules = []rules.Rule{}
 	}
 
-	if set.Shell == nil {
-		set.Shell = []string{}
+	if set.Shell == "" {
+		set.Shell = ""
 	}
 
 	if set.Commands == nil {
