diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index dea273f2..c2cb7f81 100644 --- a/dataprovider/bolt.go +++ b/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 } diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 4f368a3a..de5a6005 100644 --- a/dataprovider/dataprovider.go +++ b/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"} } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 3d713346..f2d9bff1 100644 --- a/dataprovider/memory.go +++ b/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 } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index bb881b3f..819c71e4 100644 --- a/dataprovider/sqlcommon.go +++ b/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 } diff --git a/httpd/api_admin.go b/httpd/api_admin.go index 3f623146..c0aa94dc 100644 --- a/httpd/api_admin.go +++ b/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) diff --git a/httpd/httpd.go b/httpd/httpd.go index 7a665f6e..25a037a5 100644 --- a/httpd/httpd.go +++ b/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 diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index ba547b12..89bcb617 100644 --- a/httpd/httpd_test.go +++ b/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) diff --git a/httpd/server.go b/httpd/server.go index 0685bf7c..ceced151 100644 --- a/httpd/server.go +++ b/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) { diff --git a/httpd/web.go b/httpd/web.go index 1eda766d..35f81b86 100644 --- a/httpd/web.go +++ b/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") diff --git a/templates/folder.html b/templates/folder.html index 0ef97ed8..b6aa0675 100644 --- a/templates/folder.html +++ b/templates/folder.html @@ -5,7 +5,7 @@ {{define "page_body"}}