package httpd import ( "errors" "fmt" "html/template" "io/ioutil" "net/http" "path" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/kms" "github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/version" "github.com/drakkan/sftpgo/vfs" ) const ( templateBase = "base.html" templateUsers = "users.html" templateUser = "user.html" templateConnections = "connections.html" templateFolders = "folders.html" templateFolder = "folder.html" templateMessage = "message.html" templateStatus = "status.html" pageUsersTitle = "Users" pageConnectionsTitle = "Connections" pageStatusTitle = "Status" pageFoldersTitle = "Folders" page400Title = "Bad request" page404Title = "Not found" page404Body = "The page you are looking for does not exist." page500Title = "Internal Server Error" page500Body = "The server is unable to fulfill your request." defaultQueryLimit = 500 webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS redactedSecret = "[**redacted**]" ) var ( templates = make(map[string]*template.Template) ) type basePage struct { Title string CurrentURL string UsersURL string UserURL string APIUserURL string APIConnectionsURL string APIQuotaScanURL string ConnectionsURL string FoldersURL string FolderURL string APIFoldersURL string APIFolderQuotaScanURL string StatusURL string UsersTitle string ConnectionsTitle string FoldersTitle string StatusTitle string Version string } type usersPage struct { basePage Users []dataprovider.User } type foldersPage struct { basePage Folders []vfs.BaseVirtualFolder } type connectionsPage struct { basePage Connections []common.ConnectionStatus } type statusPage struct { basePage Status ServicesStatus } type userPage struct { basePage User dataprovider.User RootPerms []string Error string ValidPerms []string ValidSSHLoginMethods []string ValidProtocols []string RootDirPerms []string RedactedSecret string IsAdd bool } type folderPage struct { basePage Folder vfs.BaseVirtualFolder Error string } type messagePage struct { basePage Error string Success string } func loadTemplates(templatesPath string) { usersPaths := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateUsers), } userPaths := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateUser), } connectionsPaths := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateConnections), } messagePath := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateMessage), } foldersPath := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateFolders), } folderPath := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateFolder), } statusPath := []string{ filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateStatus), } usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...)) userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...)) connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...)) messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...)) foldersTmpl := utils.LoadTemplate(template.ParseFiles(foldersPath...)) folderTmpl := utils.LoadTemplate(template.ParseFiles(folderPath...)) statusTmpl := utils.LoadTemplate(template.ParseFiles(statusPath...)) templates[templateUsers] = usersTmpl templates[templateUser] = userTmpl templates[templateConnections] = connectionsTmpl templates[templateMessage] = messageTmpl templates[templateFolders] = foldersTmpl templates[templateFolder] = folderTmpl templates[templateStatus] = statusTmpl } func getBasePageData(title, currentURL string) basePage { return basePage{ Title: title, CurrentURL: currentURL, UsersURL: webUsersPath, UserURL: webUserPath, FoldersURL: webFoldersPath, FolderURL: webFolderPath, APIUserURL: userPath, APIConnectionsURL: activeConnectionsPath, APIQuotaScanURL: quotaScanPath, APIFoldersURL: folderPath, APIFolderQuotaScanURL: quotaScanVFolderPath, ConnectionsURL: webConnectionsPath, StatusURL: webStatusPath, UsersTitle: pageUsersTitle, ConnectionsTitle: pageConnectionsTitle, FoldersTitle: pageFoldersTitle, StatusTitle: pageStatusTitle, Version: version.GetAsString(), } } func renderTemplate(w http.ResponseWriter, tmplName string, data interface{}) { err := templates[tmplName].ExecuteTemplate(w, tmplName, data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func renderMessagePage(w http.ResponseWriter, title, body string, statusCode int, err error, message string) { var errorString string if len(body) > 0 { errorString = body + " " } if err != nil { errorString += err.Error() } data := messagePage{ basePage: getBasePageData(title, ""), Error: errorString, Success: message, } w.WriteHeader(statusCode) renderTemplate(w, templateMessage, data) } func renderInternalServerErrorPage(w http.ResponseWriter, err error) { renderMessagePage(w, page500Title, page500Body, http.StatusInternalServerError, err, "") } func renderBadRequestPage(w http.ResponseWriter, err error) { renderMessagePage(w, page400Title, "", http.StatusBadRequest, err, "") } func renderNotFoundPage(w http.ResponseWriter, err error) { renderMessagePage(w, page404Title, page404Body, http.StatusNotFound, err, "") } func renderAddUserPage(w http.ResponseWriter, user dataprovider.User, error string) { user.SetEmptySecretsIfNil() data := userPage{ basePage: getBasePageData("Add a new user", webUserPath), IsAdd: true, Error: error, User: user, ValidPerms: dataprovider.ValidPerms, ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, ValidProtocols: dataprovider.ValidProtocols, RootDirPerms: user.GetPermissionsForPath("/"), RedactedSecret: redactedSecret, } renderTemplate(w, templateUser, data) } func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error string) { user.SetEmptySecretsIfNil() data := userPage{ basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, user.ID)), IsAdd: false, Error: error, User: user, ValidPerms: dataprovider.ValidPerms, ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, ValidProtocols: dataprovider.ValidProtocols, RootDirPerms: user.GetPermissionsForPath("/"), RedactedSecret: redactedSecret, } renderTemplate(w, templateUser, data) } func renderAddFolderPage(w http.ResponseWriter, folder vfs.BaseVirtualFolder, error string) { data := folderPage{ basePage: getBasePageData("Add a new folder", webFolderPath), Error: error, Folder: folder, } renderTemplate(w, templateFolder, data) } func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { var virtualFolders []vfs.VirtualFolder formValue := r.Form.Get("virtual_folders") for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { if strings.Contains(cleaned, "::") { mapping := strings.Split(cleaned, "::") if len(mapping) > 1 { vfolder := vfs.VirtualFolder{ BaseVirtualFolder: vfs.BaseVirtualFolder{ MappedPath: strings.TrimSpace(mapping[1]), }, VirtualPath: strings.TrimSpace(mapping[0]), QuotaFiles: -1, QuotaSize: -1, } if len(mapping) > 2 { quotaFiles, err := strconv.Atoi(strings.TrimSpace(mapping[2])) if err == nil { vfolder.QuotaFiles = quotaFiles } } if len(mapping) > 3 { quotaSize, err := strconv.ParseInt(strings.TrimSpace(mapping[3]), 10, 64) if err == nil { vfolder.QuotaSize = quotaSize } } virtualFolders = append(virtualFolders, vfolder) } } } return virtualFolders } func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { permissions := make(map[string][]string) permissions["/"] = r.Form["permissions"] subDirsPermsValue := r.Form.Get("sub_dirs_permissions") for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { if strings.Contains(cleaned, "::") { dirPerms := strings.Split(cleaned, "::") if len(dirPerms) > 1 { dir := dirPerms[0] dir = strings.TrimSpace(dir) perms := []string{} for _, p := range strings.Split(dirPerms[1], ",") { cleanedPerm := strings.TrimSpace(p) if len(cleanedPerm) > 0 { perms = append(perms, cleanedPerm) } } if len(dir) > 0 { permissions[dir] = perms } } } } return permissions } func getSliceFromDelimitedValues(values, delimiter string) []string { result := []string{} for _, v := range strings.Split(values, delimiter) { cleaned := strings.TrimSpace(v) if len(cleaned) > 0 { result = append(result, cleaned) } } return result } func getListFromPostFields(value string) map[string][]string { result := make(map[string][]string) for _, cleaned := range getSliceFromDelimitedValues(value, "\n") { if strings.Contains(cleaned, "::") { dirExts := strings.Split(cleaned, "::") if len(dirExts) > 1 { dir := dirExts[0] dir = path.Clean(strings.TrimSpace(dir)) exts := []string{} for _, e := range strings.Split(dirExts[1], ",") { cleanedExt := strings.TrimSpace(e) if cleanedExt != "" { exts = append(exts, cleanedExt) } } if dir != "" { if _, ok := result[dir]; ok { result[dir] = append(result[dir], exts...) } else { result[dir] = exts } result[dir] = utils.RemoveDuplicates(result[dir]) } } } } return result } func getFilePatternsFromPostField(valueAllowed, valuesDenied string) []dataprovider.PatternsFilter { var result []dataprovider.PatternsFilter allowedPatterns := getListFromPostFields(valueAllowed) deniedPatterns := getListFromPostFields(valuesDenied) for dirAllowed, allowPatterns := range allowedPatterns { filter := dataprovider.PatternsFilter{ Path: dirAllowed, AllowedPatterns: allowPatterns, } for dirDenied, denPatterns := range deniedPatterns { if dirAllowed == dirDenied { filter.DeniedPatterns = denPatterns break } } result = append(result, filter) } for dirDenied, denPatterns := range deniedPatterns { found := false for _, res := range result { if res.Path == dirDenied { found = true break } } if !found { result = append(result, dataprovider.PatternsFilter{ Path: dirDenied, DeniedPatterns: denPatterns, }) } } return result } func getFileExtensionsFromPostField(valueAllowed, valuesDenied string) []dataprovider.ExtensionsFilter { var result []dataprovider.ExtensionsFilter allowedExtensions := getListFromPostFields(valueAllowed) deniedExtensions := getListFromPostFields(valuesDenied) for dirAllowed, allowedExts := range allowedExtensions { filter := dataprovider.ExtensionsFilter{ Path: dirAllowed, AllowedExtensions: allowedExts, } for dirDenied, deniedExts := range deniedExtensions { if dirAllowed == dirDenied { filter.DeniedExtensions = deniedExts break } } result = append(result, filter) } for dirDenied, deniedExts := range deniedExtensions { found := false for _, res := range result { if res.Path == dirDenied { found = true break } } if !found { result = append(result, dataprovider.ExtensionsFilter{ Path: dirDenied, DeniedExtensions: deniedExts, }) } } return result } func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { var filters dataprovider.UserFilters filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",") filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",") filters.DeniedLoginMethods = r.Form["ssh_login_methods"] filters.DeniedProtocols = r.Form["denied_protocols"] filters.FileExtensions = getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), r.Form.Get("denied_extensions")) filters.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns")) return filters } func getSecretFromFormField(r *http.Request, field string) *kms.Secret { secret := kms.NewPlainSecret(r.Form.Get(field)) if strings.TrimSpace(secret.GetPayload()) == redactedSecret { secret.SetStatus(kms.SecretStatusRedacted) } if strings.TrimSpace(secret.GetPayload()) == "" { secret.SetStatus("") } return secret } func getS3Config(r *http.Request) (vfs.S3FsConfig, error) { var err error config := vfs.S3FsConfig{} config.Bucket = r.Form.Get("s3_bucket") config.Region = r.Form.Get("s3_region") config.AccessKey = r.Form.Get("s3_access_key") config.AccessSecret = getSecretFromFormField(r, "s3_access_secret") config.Endpoint = r.Form.Get("s3_endpoint") config.StorageClass = r.Form.Get("s3_storage_class") config.KeyPrefix = r.Form.Get("s3_key_prefix") config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64) if err != nil { return config, err } config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency")) return config, err } func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) { var err error config := vfs.GCSFsConfig{} config.Bucket = r.Form.Get("gcs_bucket") config.StorageClass = r.Form.Get("gcs_storage_class") config.KeyPrefix = r.Form.Get("gcs_key_prefix") autoCredentials := r.Form.Get("gcs_auto_credentials") if autoCredentials != "" { config.AutomaticCredentials = 1 } else { config.AutomaticCredentials = 0 } credentials, _, err := r.FormFile("gcs_credential_file") if err == http.ErrMissingFile { return config, nil } if err != nil { return config, err } defer credentials.Close() fileBytes, err := ioutil.ReadAll(credentials) if err != nil || len(fileBytes) == 0 { if len(fileBytes) == 0 { err = errors.New("credentials file size must be greater than 0") } return config, err } config.Credentials = kms.NewPlainSecret(string(fileBytes)) config.AutomaticCredentials = 0 return config, err } func getSFTPConfig(r *http.Request) vfs.SFTPFsConfig { config := vfs.SFTPFsConfig{} config.Endpoint = r.Form.Get("sftp_endpoint") config.Username = r.Form.Get("sftp_username") config.Password = getSecretFromFormField(r, "sftp_password") config.PrivateKey = getSecretFromFormField(r, "sftp_private_key") fingerprintsFormValue := r.Form.Get("sftp_fingerprints") config.Fingerprints = getSliceFromDelimitedValues(fingerprintsFormValue, "\n") config.Prefix = r.Form.Get("sftp_prefix") return config } func getAzureConfig(r *http.Request) (vfs.AzBlobFsConfig, error) { var err error config := vfs.AzBlobFsConfig{} config.Container = r.Form.Get("az_container") config.AccountName = r.Form.Get("az_account_name") config.AccountKey = getSecretFromFormField(r, "az_account_key") config.SASURL = r.Form.Get("az_sas_url") config.Endpoint = r.Form.Get("az_endpoint") config.KeyPrefix = r.Form.Get("az_key_prefix") config.AccessTier = r.Form.Get("az_access_tier") config.UseEmulator = len(r.Form.Get("az_use_emulator")) > 0 config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("az_upload_part_size"), 10, 64) if err != nil { return config, err } config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("az_upload_concurrency")) return config, err } func getFsConfigFromUserPostFields(r *http.Request) (dataprovider.Filesystem, error) { var fs dataprovider.Filesystem provider, err := strconv.Atoi(r.Form.Get("fs_provider")) if err != nil { provider = int(dataprovider.LocalFilesystemProvider) } fs.Provider = dataprovider.FilesystemProvider(provider) switch fs.Provider { case dataprovider.S3FilesystemProvider: config, err := getS3Config(r) if err != nil { return fs, err } fs.S3Config = config case dataprovider.AzureBlobFilesystemProvider: config, err := getAzureConfig(r) if err != nil { return fs, err } fs.AzBlobConfig = config case dataprovider.GCSFilesystemProvider: config, err := getGCSConfig(r) if err != nil { return fs, err } fs.GCSConfig = config case dataprovider.CryptedFilesystemProvider: fs.CryptConfig.Passphrase = getSecretFromFormField(r, "crypt_passphrase") case dataprovider.SFTPFilesystemProvider: fs.SFTPConfig = getSFTPConfig(r) } return fs, nil } func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { var user dataprovider.User err := r.ParseMultipartForm(maxRequestSize) if err != nil { return user, err } publicKeysFormValue := r.Form.Get("public_keys") publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n") uid, err := strconv.Atoi(r.Form.Get("uid")) if err != nil { return user, err } gid, err := strconv.Atoi(r.Form.Get("gid")) if err != nil { return user, err } maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions")) if err != nil { return user, err } quotaSize, err := strconv.ParseInt(r.Form.Get("quota_size"), 10, 64) if err != nil { return user, err } quotaFiles, err := strconv.Atoi(r.Form.Get("quota_files")) if err != nil { return user, err } bandwidthUL, err := strconv.ParseInt(r.Form.Get("upload_bandwidth"), 10, 64) if err != nil { return user, err } bandwidthDL, err := strconv.ParseInt(r.Form.Get("download_bandwidth"), 10, 64) if err != nil { return user, err } status, err := strconv.Atoi(r.Form.Get("status")) if err != nil { return user, err } expirationDateMillis := int64(0) expirationDateString := r.Form.Get("expiration_date") if len(strings.TrimSpace(expirationDateString)) > 0 { expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString) if err != nil { return user, err } expirationDateMillis = utils.GetTimeAsMsSinceEpoch(expirationDate) } fsConfig, err := getFsConfigFromUserPostFields(r) if err != nil { return user, err } user = dataprovider.User{ Username: r.Form.Get("username"), Password: r.Form.Get("password"), PublicKeys: publicKeys, HomeDir: r.Form.Get("home_dir"), VirtualFolders: getVirtualFoldersFromPostFields(r), UID: uid, GID: gid, Permissions: getUserPermissionsFromPostFields(r), MaxSessions: maxSessions, QuotaSize: quotaSize, QuotaFiles: quotaFiles, UploadBandwidth: bandwidthUL, DownloadBandwidth: bandwidthDL, Status: status, ExpirationDate: expirationDateMillis, Filters: getFiltersFromUserPostFields(r), FsConfig: fsConfig, AdditionalInfo: r.Form.Get("additional_info"), } maxFileSize, err := strconv.ParseInt(r.Form.Get("max_upload_file_size"), 10, 64) user.Filters.MaxUploadFileSize = maxFileSize return user, err } func handleGetWebUsers(w http.ResponseWriter, r *http.Request) { limit := defaultQueryLimit if _, ok := r.URL.Query()["qlimit"]; ok { var err error limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) if err != nil { limit = defaultQueryLimit } } users := make([]dataprovider.User, 0, limit) for { u, err := dataprovider.GetUsers(limit, len(users), dataprovider.OrderASC, "") if err != nil { renderInternalServerErrorPage(w, err) return } users = append(users, u...) if len(u) < limit { break } } data := usersPage{ basePage: getBasePageData(pageUsersTitle, webUsersPath), Users: users, } renderTemplate(w, templateUsers, data) } func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { renderAddUserPage(w, dataprovider.User{Status: 1}, "") } func handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) if err != nil { renderBadRequestPage(w, err) return } user, err := dataprovider.GetUserByID(id) if err == nil { renderUpdateUserPage(w, user, "") } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { renderNotFoundPage(w, err) } else { renderInternalServerErrorPage(w, err) } } func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) user, err := getUserFromPostFields(r) if err != nil { renderAddUserPage(w, user, err.Error()) return } err = dataprovider.AddUser(user) if err == nil { http.Redirect(w, r, webUsersPath, http.StatusSeeOther) } else { renderAddUserPage(w, user, err.Error()) } } func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) id, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) if err != nil { renderBadRequestPage(w, err) return } user, err := dataprovider.GetUserByID(id) if _, ok := err.(*dataprovider.RecordNotFoundError); ok { renderNotFoundPage(w, err) return } else if err != nil { renderInternalServerErrorPage(w, err) return } updatedUser, err := getUserFromPostFields(r) if err != nil { renderUpdateUserPage(w, user, err.Error()) return } updatedUser.ID = user.ID updatedUser.SetEmptySecretsIfNil() if len(updatedUser.Password) == 0 { updatedUser.Password = user.Password } updateEncryptedSecrets(&updatedUser, user.FsConfig.S3Config.AccessSecret, user.FsConfig.AzBlobConfig.AccountKey, user.FsConfig.GCSConfig.Credentials, user.FsConfig.CryptConfig.Passphrase, user.FsConfig.SFTPConfig.Password, user.FsConfig.SFTPConfig.PrivateKey) err = dataprovider.UpdateUser(updatedUser) if err == nil { if len(r.Form.Get("disconnect")) > 0 { disconnectUser(user.Username) } http.Redirect(w, r, webUsersPath, http.StatusSeeOther) } else { renderUpdateUserPage(w, user, err.Error()) } } func handleWebGetStatus(w http.ResponseWriter, r *http.Request) { data := statusPage{ basePage: getBasePageData(pageStatusTitle, webStatusPath), Status: getServicesStatus(), } renderTemplate(w, templateStatus, data) } func handleWebGetConnections(w http.ResponseWriter, r *http.Request) { connectionStats := common.Connections.GetStats() data := connectionsPage{ basePage: getBasePageData(pageConnectionsTitle, webConnectionsPath), Connections: connectionStats, } renderTemplate(w, templateConnections, data) } func handleWebAddFolderGet(w http.ResponseWriter, r *http.Request) { renderAddFolderPage(w, vfs.BaseVirtualFolder{}, "") } func handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) folder := vfs.BaseVirtualFolder{} err := r.ParseForm() if err != nil { renderAddFolderPage(w, folder, err.Error()) return } folder.MappedPath = r.Form.Get("mapped_path") err = dataprovider.AddFolder(folder) if err == nil { http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) } else { renderAddFolderPage(w, folder, err.Error()) } } func handleWebGetFolders(w http.ResponseWriter, r *http.Request) { limit := defaultQueryLimit if _, ok := r.URL.Query()["qlimit"]; ok { var err error limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) if err != nil { limit = defaultQueryLimit } } folders := make([]vfs.BaseVirtualFolder, 0, limit) for { f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC, "") if err != nil { renderInternalServerErrorPage(w, err) return } folders = append(folders, f...) if len(f) < limit { break } } data := foldersPage{ basePage: getBasePageData(pageFoldersTitle, webFoldersPath), Folders: folders, } renderTemplate(w, templateFolders, data) }