浏览代码

web ui: allow to create folders from a template

Nicola Murino 4 年之前
父节点
当前提交
267d9f1831

+ 2 - 2
dataprovider/bolt.go

@@ -677,7 +677,7 @@ func (p *BoltProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, erro
 }
 
 func (p *BoltProvider) addFolder(folder *vfs.BaseVirtualFolder) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}
@@ -696,7 +696,7 @@ func (p *BoltProvider) addFolder(folder *vfs.BaseVirtualFolder) error {
 }
 
 func (p *BoltProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}

+ 5 - 3
dataprovider/dataprovider.go

@@ -997,7 +997,7 @@ func validateFolderQuotaLimits(folder vfs.VirtualFolder) error {
 }
 
 func getVirtualFolderIfInvalid(folder *vfs.BaseVirtualFolder) *vfs.BaseVirtualFolder {
-	if err := validateFolder(folder); err == nil {
+	if err := ValidateFolder(folder); err == nil {
 		return folder
 	}
 	// we try to get the folder from the data provider if only the Name is populated
@@ -1029,7 +1029,7 @@ func validateUserVirtualFolders(user *User) error {
 			return err
 		}
 		folder := getVirtualFolderIfInvalid(&v.BaseVirtualFolder)
-		if err := validateFolder(folder); err != nil {
+		if err := ValidateFolder(folder); err != nil {
 			return err
 		}
 		cleanedMPath := folder.MappedPath
@@ -1388,7 +1388,9 @@ func createUserPasswordHash(user *User) error {
 	return nil
 }
 
-func validateFolder(folder *vfs.BaseVirtualFolder) error {
+// ValidateFolder returns an error if the folder is not valid
+// FIXME: this should be defined as Folder struct method
+func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
 	if folder.Name == "" {
 		return &ValidationError{err: "folder name is mandatory"}
 	}

+ 2 - 2
dataprovider/memory.go

@@ -651,7 +651,7 @@ func (p *MemoryProvider) getFolderByName(name string) (vfs.BaseVirtualFolder, er
 }
 
 func (p *MemoryProvider) addFolder(folder *vfs.BaseVirtualFolder) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}
@@ -675,7 +675,7 @@ func (p *MemoryProvider) addFolder(folder *vfs.BaseVirtualFolder) error {
 }
 
 func (p *MemoryProvider) updateFolder(folder *vfs.BaseVirtualFolder) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}

+ 2 - 2
dataprovider/sqlcommon.go

@@ -669,7 +669,7 @@ func sqlCommonAddOrGetFolder(ctx context.Context, baseFolder vfs.BaseVirtualFold
 }
 
 func sqlCommonAddFolder(folder *vfs.BaseVirtualFolder, dbHandle sqlQuerier) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}
@@ -688,7 +688,7 @@ func sqlCommonAddFolder(folder *vfs.BaseVirtualFolder, dbHandle sqlQuerier) erro
 }
 
 func sqlCommonUpdateFolder(folder *vfs.BaseVirtualFolder, dbHandle *sql.DB) error {
-	err := validateFolder(folder)
+	err := ValidateFolder(folder)
 	if err != nil {
 		return err
 	}

+ 3 - 31
httpd/api_admin.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"errors"
 	"net/http"
-	"strconv"
 
 	"github.com/go-chi/jwtauth"
 	"github.com/go-chi/render"
@@ -18,36 +17,9 @@ type adminPwd struct {
 }
 
 func getAdmins(w http.ResponseWriter, r *http.Request) {
-	limit := 100
-	offset := 0
-	order := dataprovider.OrderASC
-	var err error
-	if _, ok := r.URL.Query()["limit"]; ok {
-		limit, err = strconv.Atoi(r.URL.Query().Get("limit"))
-		if err != nil {
-			err = errors.New("Invalid limit")
-			sendAPIResponse(w, r, err, "", http.StatusBadRequest)
-			return
-		}
-		if limit > 500 {
-			limit = 500
-		}
-	}
-	if _, ok := r.URL.Query()["offset"]; ok {
-		offset, err = strconv.Atoi(r.URL.Query().Get("offset"))
-		if err != nil {
-			err = errors.New("Invalid offset")
-			sendAPIResponse(w, r, err, "", http.StatusBadRequest)
-			return
-		}
-	}
-	if _, ok := r.URL.Query()["order"]; ok {
-		order = r.URL.Query().Get("order")
-		if order != dataprovider.OrderASC && order != dataprovider.OrderDESC {
-			err = errors.New("Invalid order")
-			sendAPIResponse(w, r, err, "", http.StatusBadRequest)
-			return
-		}
+	limit, offset, order, err := getSearchFilters(w, r)
+	if err != nil {
+		return
 	}
 
 	admins, err := dataprovider.GetAdmins(limit, offset, order)

+ 1 - 0
httpd/httpd.go

@@ -65,6 +65,7 @@ const (
 	webQuotaScanPath          = "/web/quota-scans"
 	webChangeAdminPwdPath     = "/web/changepwd/admin"
 	webTemplateUser           = "/web/template/user"
+	webTemplateFolder         = "/web/template/folder"
 	webStaticFilesPath        = "/static"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize = 10485760 // 10 MB

+ 110 - 5
httpd/httpd_test.go

@@ -81,6 +81,7 @@ const (
 	webRestorePath            = "/web/restore"
 	webChangeAdminPwdPath     = "/web/changepwd/admin"
 	webTemplateUser           = "/web/template/user"
+	webTemplateFolder         = "/web/template/folder"
 	httpBaseURL               = "http://127.0.0.1:8081"
 	configDir                 = ".."
 	httpsCert                 = `-----BEGIN CERTIFICATE-----
@@ -2325,6 +2326,11 @@ func TestProviderErrors(t *testing.T) {
 	setJWTCookieForReq(req, testServerToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
+	req, err = http.NewRequest(http.MethodGet, webTemplateFolder+"?from=afolder", nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, testServerToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
@@ -4803,6 +4809,38 @@ func TestWebUserUpdateMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 }
 
+func TestRenderFolderTemplateMock(t *testing.T) {
+	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, webTemplateFolder, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	folder := vfs.BaseVirtualFolder{
+		Name:       "templatefolder",
+		MappedPath: filepath.Join(os.TempDir(), "mapped"),
+	}
+	folder, _, err = httpdtest.AddFolder(folder, http.StatusCreated)
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, webTemplateFolder+fmt.Sprintf("?from=%v", folder.Name), nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webTemplateFolder+"?from=unknown-folder", nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestRenderUserTemplateMock(t *testing.T) {
 	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
@@ -4976,7 +5014,7 @@ func TestUserTemplateMock(t *testing.T) {
 	// test invalid s3_upload_part_size
 	form.Set("s3_upload_part_size", "a")
 	b, contentType, _ := getMultipartFormData(form, "", "")
-	req, _ := http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	req, _ := http.NewRequest(http.MethodPost, webTemplateUser, &b)
 	setJWTCookieForReq(req, token)
 	req.Header.Set("Content-Type", contentType)
 	rr := executeRequest(req)
@@ -4985,7 +5023,7 @@ func TestUserTemplateMock(t *testing.T) {
 	form.Set("s3_upload_concurrency", strconv.Itoa(user.FsConfig.S3Config.UploadConcurrency))
 
 	b, contentType, _ = getMultipartFormData(form, "", "")
-	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
 	setJWTCookieForReq(req, token)
 	req.Header.Set("Content-Type", contentType)
 	rr = executeRequest(req)
@@ -4993,7 +5031,7 @@ func TestUserTemplateMock(t *testing.T) {
 
 	form.Set("users", "user1::password1::invalid-pkey")
 	b, contentType, _ = getMultipartFormData(form, "", "")
-	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
 	setJWTCookieForReq(req, token)
 	req.Header.Set("Content-Type", contentType)
 	rr = executeRequest(req)
@@ -5002,7 +5040,7 @@ func TestUserTemplateMock(t *testing.T) {
 
 	form.Set("users", "user1:password1")
 	b, contentType, _ = getMultipartFormData(form, "", "")
-	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
 	setJWTCookieForReq(req, token)
 	req.Header.Set("Content-Type", contentType)
 	rr = executeRequest(req)
@@ -5011,7 +5049,7 @@ func TestUserTemplateMock(t *testing.T) {
 
 	form.Set("users", "user1::password1\nuser2::password2::"+testPubKey+"\nuser3::::")
 	b, contentType, _ = getMultipartFormData(form, "", "")
-	req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
+	req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
 	setJWTCookieForReq(req, token)
 	req.Header.Set("Content-Type", contentType)
 	rr = executeRequest(req)
@@ -5021,6 +5059,8 @@ func TestUserTemplateMock(t *testing.T) {
 	err = json.Unmarshal(rr.Body.Bytes(), &dump)
 	require.NoError(t, err)
 	require.Len(t, dump.Users, 2)
+	require.Len(t, dump.Admins, 0)
+	require.Len(t, dump.Folders, 0)
 	user1 := dump.Users[0]
 	user2 := dump.Users[1]
 	require.Equal(t, "user1", user1.Username)
@@ -5044,6 +5084,71 @@ func TestUserTemplateMock(t *testing.T) {
 	require.Equal(t, "password2", user2.FsConfig.S3Config.AccessSecret.GetPayload())
 }
 
+func TestFolderTemplateMock(t *testing.T) {
+	folderName := "vfolder-template"
+	mappedPath := filepath.Join(os.TempDir(), "%name%mapped%name%path")
+	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken()
+	assert.NoError(t, err)
+	form := make(url.Values)
+	form.Set("name", folderName)
+	form.Set("mapped_path", mappedPath)
+	form.Set("folders", "folder1\nfolder2\nfolder3\nfolder1\n\n\n")
+	contentType := "application/x-www-form-urlencoded"
+	req, _ := http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to verify form token")
+
+	form.Set(csrfFormToken, csrfToken)
+
+	req, _ = http.NewRequest(http.MethodPost, webTemplateFolder+"?param=p%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode())))
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Error parsing folders fields")
+
+	req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	var dump dataprovider.BackupData
+	err = json.Unmarshal(rr.Body.Bytes(), &dump)
+	require.NoError(t, err)
+	require.Len(t, dump.Users, 0)
+	require.Len(t, dump.Admins, 0)
+	require.Len(t, dump.Folders, 3)
+	require.Equal(t, "folder1", dump.Folders[0].Name)
+	require.True(t, strings.HasSuffix(dump.Folders[0].MappedPath, "folder1mappedfolder1path"))
+	require.Equal(t, "folder2", dump.Folders[1].Name)
+	require.True(t, strings.HasSuffix(dump.Folders[1].MappedPath, "folder2mappedfolder2path"))
+	require.Equal(t, "folder3", dump.Folders[2].Name)
+	require.True(t, strings.HasSuffix(dump.Folders[2].MappedPath, "folder3mappedfolder3path"))
+
+	form.Set("folders", "\n\n\n")
+	req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "No folders to export")
+
+	form.Set("folders", "name")
+	form.Set("mapped_path", "relative-path")
+	req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, bytes.NewBuffer([]byte(form.Encode())))
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Error validating folder")
+}
+
 func TestWebUserS3Mock(t *testing.T) {
 	webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)

+ 3 - 0
httpd/server.go

@@ -392,6 +392,9 @@ func (s *httpdServer) initializeRouter() {
 				router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
 					Get(webTemplateUser, handleWebTemplateUserGet)
 				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateUser, handleWebTemplateUserPost)
+				router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
+					Get(webTemplateFolder, handleWebTemplateFolderGet)
+				router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, handleWebTemplateFolderPost)
 			})
 
 			router.Group(func(router chi.Router) {

+ 85 - 0
httpd/web.go

@@ -36,6 +36,7 @@ type folderPageMode int
 const (
 	folderPageModeAdd folderPageMode = iota + 1
 	folderPageModeUpdate
+	folderPageModeTemplate
 )
 
 const (
@@ -88,6 +89,7 @@ type basePage struct {
 	ConnectionsURL     string
 	FoldersURL         string
 	FolderURL          string
+	FolderTemplateURL  string
 	LogoutURL          string
 	ChangeAdminPwdURL  string
 	FolderQuotaScanURL string
@@ -277,6 +279,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		AdminURL:           webAdminPath,
 		FoldersURL:         webFoldersPath,
 		FolderURL:          webFolderPath,
+		FolderTemplateURL:  webTemplateFolder,
 		LogoutURL:          webLogoutPath,
 		ChangeAdminPwdURL:  webChangeAdminPwdPath,
 		QuotaScanURL:       webQuotaScanPath,
@@ -409,6 +412,9 @@ func renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVir
 	case folderPageModeUpdate:
 		title = "Update folder"
 		currentURL = fmt.Sprintf("%v/%v", webFolderPath, url.PathEscape(folder.Name))
+	case folderPageModeTemplate:
+		title = "Folder template"
+		currentURL = webTemplateFolder
 	}
 	data := folderPage{
 		basePage: getBasePageData(title, currentURL, r),
@@ -419,6 +425,20 @@ func renderFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.BaseVir
 	renderTemplate(w, templateFolder, data)
 }
 
+func getFoldersForTemplate(r *http.Request) []string {
+	var res []string
+	formValue := r.Form.Get("folders")
+	folders := make(map[string]bool)
+	for _, name := range getSliceFromDelimitedValues(formValue, "\n") {
+		if _, ok := folders[name]; ok {
+			continue
+		}
+		folders[name] = true
+		res = append(res, name)
+	}
+	return res
+}
+
 func getUsersForTemplate(r *http.Request) []userTemplateFields {
 	var res []userTemplateFields
 	formValue := r.Form.Get("users")
@@ -789,6 +809,16 @@ func replacePlaceholders(field string, replacements map[string]string) string {
 	return field
 }
 
+func getFolderFromTemplate(folder vfs.BaseVirtualFolder, name string) vfs.BaseVirtualFolder {
+	folder.Name = name
+	replacements := make(map[string]string)
+	replacements["%name%"] = folder.Name
+
+	folder.MappedPath = replacePlaceholders(folder.MappedPath, replacements)
+
+	return folder
+}
+
 func getCryptFsFromTemplate(fsConfig vfs.CryptFsConfig, replacements map[string]string) vfs.CryptFsConfig {
 	if fsConfig.Passphrase != nil {
 		if fsConfig.Passphrase.IsPlain() {
@@ -1185,6 +1215,61 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
 	renderTemplate(w, templateUsers, data)
 }
 
+func handleWebTemplateFolderGet(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Query().Get("from") != "" {
+		name := r.URL.Query().Get("from")
+		folder, err := dataprovider.GetFolderByName(name)
+		if err == nil {
+			renderFolderPage(w, r, folder, folderPageModeTemplate, "")
+		} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
+			renderNotFoundPage(w, r, err)
+		} else {
+			renderInternalServerErrorPage(w, r, err)
+		}
+	} else {
+		folder := vfs.BaseVirtualFolder{}
+		renderFolderPage(w, r, folder, folderPageModeTemplate, "")
+	}
+}
+
+func handleWebTemplateFolderPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	templateFolder := vfs.BaseVirtualFolder{}
+	err := r.ParseForm()
+	if err != nil {
+		renderMessagePage(w, r, "Error parsing folders fields", "", http.StatusBadRequest, err, "")
+		return
+	}
+
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderForbiddenPage(w, r, err.Error())
+		return
+	}
+
+	templateFolder.MappedPath = r.Form.Get("mapped_path")
+
+	var dump dataprovider.BackupData
+	dump.Version = dataprovider.DumpVersion
+
+	foldersFields := getFoldersForTemplate(r)
+	for _, tmpl := range foldersFields {
+		f := getFolderFromTemplate(templateFolder, tmpl)
+		if err := dataprovider.ValidateFolder(&f); err != nil {
+			renderMessagePage(w, r, fmt.Sprintf("Error validating folder %#v", f.Name), "", http.StatusBadRequest, err, "")
+			return
+		}
+		dump.Folders = append(dump.Folders, f)
+	}
+
+	if len(dump.Folders) == 0 {
+		renderMessagePage(w, r, "No folders to export", "No valid folders found, export is not possible", http.StatusBadRequest, nil, "")
+		return
+	}
+
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-folders-from-template.json\"", len(dump.Folders)))
+	render.JSON(w, r, dump)
+}
+
 func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) {
 	if r.URL.Query().Get("from") != "" {
 		username := r.URL.Query().Get("from")

+ 31 - 3
templates/folder.html

@@ -5,7 +5,7 @@
 {{define "page_body"}}
 <div class="card shadow mb-4">
     <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">Add a new folder</h6>
+        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
     </div>
     <div class="card-body">
         {{if .Error}}
@@ -13,7 +13,34 @@
             <div class="card-body text-form-error">{{.Error}}</div>
         </div>
         {{end}}
-        <form id="folder_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+        {{if eq .Mode 3}}
+        <div class="card mb-4 border-left-info">
+            <div class="card-body">
+                Generate a data provider independent JSON file to create new folders or update existing ones.
+                <br>
+                The following placeholder is supported:
+                <br><br>
+                <ul>
+                    <li><span class="text-success">%name%</span> will be replaced with the specified folder name</li>
+                </ul>
+                The generated folders file can be imported from the "Maintenance" section.
+            </div>
+        </div>
+        {{end}}
+        <form id="folder_form" action="{{.CurrentURL}}" method="POST" autocomplete="off" {{if eq .Mode 3}}target="_blank"{{end}}>
+            {{if eq .Mode 3}}
+            <div class="form-group row">
+                <label for="idFolders" class="col-sm-2 col-form-label">Folders</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idFolders" name="folders" rows="5" required
+                        aria-describedby="foldersHelpBlock"></textarea>
+                    <small id="foldersHelpBlock" class="form-text text-muted">
+                        Specify the folder names, one for line.
+                    </small>
+                </div>
+            </div>
+            <input type="hidden" name="name" id="idFolderName" value="{{.Folder.Name}}">
+            {{else}}
             <div class="form-group row">
                 <label for="idFolderName" class="col-sm-2 col-form-label">Name</label>
                 <div class="col-sm-10">
@@ -21,6 +48,7 @@
                         value="{{.Folder.Name}}" maxlength="255" autocomplete="nope" required {{if ge .Mode 2}}readonly{{end}}>
                 </div>
             </div>
+            {{end}}
             <div class="form-group row">
                 <label for="idMappedPath" class="col-sm-2 col-form-label">Absolute Path</label>
                 <div class="col-sm-10">
@@ -30,7 +58,7 @@
             </div>
 
             <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">{{if eq .Mode 3}}Generate and export folders{{else}}Submit{{end}}</button>
         </form>
     </div>
 </div>

+ 19 - 0
templates/folders.html

@@ -141,6 +141,21 @@ function deleteAction() {
             enabled: false
         };
 
+        $.fn.dataTable.ext.buttons.template = {
+            text: 'Template',
+            name: 'template',
+            action: function (e, dt, node, config) {
+                var selectedRows = table.rows({ selected: true }).count();
+                if (selectedRows == 1){
+                    var folderName = table.row({ selected: true }).data()[0];
+                    var path = '{{.FolderTemplateURL}}' + "?from=" + encodeURIComponent(folderName);
+                    window.location.href = path;
+                } else {
+                    window.location.href = '{{.FolderTemplateURL}}';
+                }
+            }
+        };
+
         $.fn.dataTable.ext.buttons.delete = {
             text: 'Delete',
             name: 'delete',
@@ -213,6 +228,10 @@ function deleteAction() {
         table.button().add(0,'quota_scan');
         {{end}}
 
+        {{if .LoggedAdmin.HasPermission "manage_system"}}
+        table.button().add(0,'template');
+        {{end}}
+
         {{if .LoggedAdmin.HasPermission "del_users"}}
         table.button().add(0,'delete');
         {{end}}

+ 1 - 2
webdavd/server.go

@@ -37,8 +37,7 @@ type webDavServer struct {
 	binding Binding
 }
 
-func (s *webDavServer) listenAndServe() error {
-	compressor := middleware.NewCompressor(5, "text/*")
+func (s *webDavServer) listenAndServe(compressor *middleware.Compressor) error {
 	handler := compressor.Handler(s)
 	httpServer := &http.Server{
 		Addr:              s.binding.GetAddress(),

+ 4 - 1
webdavd/webdavd.go

@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"path/filepath"
 
+	"github.com/go-chi/chi/middleware"
+
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/utils"
@@ -157,6 +159,7 @@ func (c *Configuration) Initialize(configDir string) error {
 		}
 		certMgr = mgr
 	}
+	compressor := middleware.NewCompressor(5, "text/*")
 
 	serviceStatus = ServiceStatus{
 		Bindings: nil,
@@ -174,7 +177,7 @@ func (c *Configuration) Initialize(configDir string) error {
 				config:  c,
 				binding: binding,
 			}
-			exitChannel <- server.listenAndServe()
+			exitChannel <- server.listenAndServe(compressor)
 		}(binding)
 	}