1770da545d
Please note that if the upload bandwidth between the SFTP client and SFTPGo is greater than the upload bandwidth between SFTPGo and S3 then the SFTP client have to wait for the upload of the last parts to S3 after it ends the file upload to SFTPGo, and it may time out. Keep this in mind if you customize parts size and upload concurrency
551 lines
16 KiB
Go
551 lines
16 KiB
Go
package httpd
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/drakkan/sftpgo/dataprovider"
|
|
"github.com/drakkan/sftpgo/sftpd"
|
|
"github.com/drakkan/sftpgo/utils"
|
|
"github.com/drakkan/sftpgo/vfs"
|
|
)
|
|
|
|
const (
|
|
templateBase = "base.html"
|
|
templateUsers = "users.html"
|
|
templateUser = "user.html"
|
|
templateConnections = "connections.html"
|
|
templateMessage = "message.html"
|
|
pageUsersTitle = "Users"
|
|
pageConnectionsTitle = "Connections"
|
|
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."
|
|
defaultUsersQueryLimit = 500
|
|
webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
|
|
)
|
|
|
|
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
|
|
UsersTitle string
|
|
ConnectionsTitle string
|
|
Version string
|
|
}
|
|
|
|
type usersPage struct {
|
|
basePage
|
|
Users []dataprovider.User
|
|
}
|
|
|
|
type connectionsPage struct {
|
|
basePage
|
|
Connections []sftpd.ConnectionStatus
|
|
}
|
|
|
|
type userPage struct {
|
|
basePage
|
|
IsAdd bool
|
|
User dataprovider.User
|
|
RootPerms []string
|
|
Error string
|
|
ValidPerms []string
|
|
ValidSSHLoginMethods []string
|
|
RootDirPerms []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),
|
|
}
|
|
usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...))
|
|
userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...))
|
|
connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...))
|
|
messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...))
|
|
|
|
templates[templateUsers] = usersTmpl
|
|
templates[templateUser] = userTmpl
|
|
templates[templateConnections] = connectionsTmpl
|
|
templates[templateMessage] = messageTmpl
|
|
}
|
|
|
|
func getBasePageData(title, currentURL string) basePage {
|
|
version := utils.GetAppVersion()
|
|
return basePage{
|
|
Title: title,
|
|
CurrentURL: currentURL,
|
|
UsersURL: webUsersPath,
|
|
UserURL: webUserPath,
|
|
APIUserURL: userPath,
|
|
APIConnectionsURL: activeConnectionsPath,
|
|
APIQuotaScanURL: quotaScanPath,
|
|
ConnectionsURL: webConnectionsPath,
|
|
UsersTitle: pageUsersTitle,
|
|
ConnectionsTitle: pageConnectionsTitle,
|
|
Version: version.GetVersionAsString(),
|
|
}
|
|
}
|
|
|
|
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, page400Title, 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) {
|
|
data := userPage{
|
|
basePage: getBasePageData("Add a new user", webUserPath),
|
|
IsAdd: true,
|
|
Error: error,
|
|
User: user,
|
|
ValidPerms: dataprovider.ValidPerms,
|
|
ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods,
|
|
RootDirPerms: user.GetPermissionsForPath("/"),
|
|
}
|
|
renderTemplate(w, templateUser, data)
|
|
}
|
|
|
|
func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error string) {
|
|
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,
|
|
RootDirPerms: user.GetPermissionsForPath("/"),
|
|
}
|
|
renderTemplate(w, templateUser, 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 {
|
|
virtualFolders = append(virtualFolders, vfs.VirtualFolder{
|
|
VirtualPath: strings.TrimSpace(mapping[0]),
|
|
MappedPath: strings.TrimSpace(mapping[1]),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
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 getFileExtensionsFromPostField(value string, extesionsType int) []dataprovider.ExtensionsFilter {
|
|
var result []dataprovider.ExtensionsFilter
|
|
for _, cleaned := range getSliceFromDelimitedValues(value, "\n") {
|
|
if strings.Contains(cleaned, "::") {
|
|
dirExts := strings.Split(cleaned, "::")
|
|
if len(dirExts) > 1 {
|
|
dir := dirExts[0]
|
|
dir = strings.TrimSpace(dir)
|
|
exts := []string{}
|
|
for _, e := range strings.Split(dirExts[1], ",") {
|
|
cleanedExt := strings.TrimSpace(e)
|
|
if len(cleanedExt) > 0 {
|
|
exts = append(exts, cleanedExt)
|
|
}
|
|
}
|
|
if len(dir) > 0 {
|
|
filter := dataprovider.ExtensionsFilter{
|
|
Path: dir,
|
|
}
|
|
if extesionsType == 1 {
|
|
filter.AllowedExtensions = exts
|
|
filter.DeniedExtensions = []string{}
|
|
} else {
|
|
filter.DeniedExtensions = exts
|
|
filter.AllowedExtensions = []string{}
|
|
}
|
|
result = append(result, filter)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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"]
|
|
allowedExtensions := getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), 1)
|
|
deniedExtensions := getFileExtensionsFromPostField(r.Form.Get("denied_extensions"), 2)
|
|
extensions := []dataprovider.ExtensionsFilter{}
|
|
if len(allowedExtensions) > 0 && len(deniedExtensions) > 0 {
|
|
for _, allowed := range allowedExtensions {
|
|
for _, denied := range deniedExtensions {
|
|
if path.Clean(allowed.Path) == path.Clean(denied.Path) {
|
|
allowed.DeniedExtensions = append(allowed.DeniedExtensions, denied.DeniedExtensions...)
|
|
}
|
|
}
|
|
extensions = append(extensions, allowed)
|
|
}
|
|
for _, denied := range deniedExtensions {
|
|
found := false
|
|
for _, allowed := range allowedExtensions {
|
|
if path.Clean(denied.Path) == path.Clean(allowed.Path) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
extensions = append(extensions, denied)
|
|
}
|
|
}
|
|
} else if len(allowedExtensions) > 0 {
|
|
extensions = append(extensions, allowedExtensions...)
|
|
} else if len(deniedExtensions) > 0 {
|
|
extensions = append(extensions, deniedExtensions...)
|
|
}
|
|
filters.FileExtensions = extensions
|
|
return filters
|
|
}
|
|
|
|
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 = 0
|
|
}
|
|
fs.Provider = provider
|
|
if fs.Provider == 1 {
|
|
fs.S3Config.Bucket = r.Form.Get("s3_bucket")
|
|
fs.S3Config.Region = r.Form.Get("s3_region")
|
|
fs.S3Config.AccessKey = r.Form.Get("s3_access_key")
|
|
fs.S3Config.AccessSecret = r.Form.Get("s3_access_secret")
|
|
fs.S3Config.Endpoint = r.Form.Get("s3_endpoint")
|
|
fs.S3Config.StorageClass = r.Form.Get("s3_storage_class")
|
|
fs.S3Config.KeyPrefix = r.Form.Get("s3_key_prefix")
|
|
fs.S3Config.UploadPartSize, err = strconv.ParseInt(r.Form.Get("s3_upload_part_size"), 10, 64)
|
|
if err != nil {
|
|
return fs, err
|
|
}
|
|
fs.S3Config.UploadConcurrency, err = strconv.Atoi(r.Form.Get("s3_upload_concurrency"))
|
|
if err != nil {
|
|
return fs, err
|
|
}
|
|
} else if fs.Provider == 2 {
|
|
fs.GCSConfig.Bucket = r.Form.Get("gcs_bucket")
|
|
fs.GCSConfig.StorageClass = r.Form.Get("gcs_storage_class")
|
|
fs.GCSConfig.KeyPrefix = r.Form.Get("gcs_key_prefix")
|
|
autoCredentials := r.Form.Get("gcs_auto_credentials")
|
|
if len(autoCredentials) > 0 {
|
|
fs.GCSConfig.AutomaticCredentials = 1
|
|
} else {
|
|
fs.GCSConfig.AutomaticCredentials = 0
|
|
}
|
|
credentials, _, err := r.FormFile("gcs_credential_file")
|
|
if err == http.ErrMissingFile {
|
|
return fs, nil
|
|
}
|
|
if err != nil {
|
|
return fs, 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 fs, err
|
|
}
|
|
fs.GCSConfig.Credentials = base64.StdEncoding.EncodeToString(fileBytes)
|
|
fs.GCSConfig.AutomaticCredentials = 0
|
|
}
|
|
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,
|
|
}
|
|
return user, err
|
|
}
|
|
|
|
func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
|
|
limit := defaultUsersQueryLimit
|
|
if _, ok := r.URL.Query()["qlimit"]; ok {
|
|
var err error
|
|
limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
|
|
if err != nil {
|
|
limit = defaultUsersQueryLimit
|
|
}
|
|
}
|
|
var users []dataprovider.User
|
|
u, err := dataprovider.GetUsers(dataProvider, limit, 0, "ASC", "")
|
|
users = append(users, u...)
|
|
for len(u) == limit {
|
|
u, err = dataprovider.GetUsers(dataProvider, limit, len(users), "ASC", "")
|
|
if err == nil && len(u) > 0 {
|
|
users = append(users, u...)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
renderInternalServerErrorPage(w, err)
|
|
return
|
|
}
|
|
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(userID string, w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(userID, 10, 64)
|
|
if err != nil {
|
|
renderBadRequestPage(w, err)
|
|
return
|
|
}
|
|
user, err := dataprovider.GetUserByID(dataProvider, 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(dataProvider, user)
|
|
if err == nil {
|
|
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
|
} else {
|
|
renderAddUserPage(w, user, err.Error())
|
|
}
|
|
}
|
|
|
|
func handleWebUpdateUserPost(userID string, w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
|
id, err := strconv.ParseInt(userID, 10, 64)
|
|
if err != nil {
|
|
renderBadRequestPage(w, err)
|
|
return
|
|
}
|
|
user, err := dataprovider.GetUserByID(dataProvider, 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
|
|
if len(updatedUser.Password) == 0 {
|
|
updatedUser.Password = user.Password
|
|
}
|
|
err = dataprovider.UpdateUser(dataProvider, updatedUser)
|
|
if err == nil {
|
|
http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
|
|
} else {
|
|
renderUpdateUserPage(w, user, err.Error())
|
|
}
|
|
}
|
|
|
|
func handleWebGetConnections(w http.ResponseWriter, r *http.Request) {
|
|
connectionStats := sftpd.GetConnectionsStats()
|
|
data := connectionsPage{
|
|
basePage: getBasePageData(pageConnectionsTitle, webConnectionsPath),
|
|
Connections: connectionStats,
|
|
}
|
|
renderTemplate(w, templateConnections, data)
|
|
}
|