浏览代码

webadmin: add defender page

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

+ 1 - 1
dataprovider/admin.go

@@ -66,7 +66,7 @@ type Admin struct {
 }
 
 func (a *Admin) checkPassword() error {
-	if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
+	if a.Password != "" && !utils.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
 		if config.PasswordHashing.Algo == HashingAlgoBcrypt {
 			pwd, err := bcrypt.GenerateFromPassword([]byte(a.Password), config.PasswordHashing.BcryptOptions.Cost)
 			if err != nil {

+ 8 - 7
dataprovider/dataprovider.go

@@ -118,13 +118,14 @@ var (
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
 	ErrNoInitRequired = errors.New("the data provider is up to date")
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
-	ErrInvalidCredentials = errors.New("invalid credentials")
-	isAdminCreated        = int32(0)
-	validTLSUsernames     = []string{string(TLSUsernameNone), string(TLSUsernameCN)}
-	config                Config
-	provider              Provider
-	sqlPlaceholders       []string
-	hashPwdPrefixes       = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
+	ErrInvalidCredentials   = errors.New("invalid credentials")
+	isAdminCreated          = int32(0)
+	validTLSUsernames       = []string{string(TLSUsernameNone), string(TLSUsernameCN)}
+	config                  Config
+	provider                Provider
+	sqlPlaceholders         []string
+	internalHashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix}
+	hashPwdPrefixes         = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
 		pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
 	pbkdfPwdPrefixes        = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}

+ 3 - 6
docs/defender.md

@@ -26,13 +26,10 @@ The `ban_time_increment` is calculated as percentage of `ban_time`, so if `ban_t
 
 The `defender` will keep in memory both the host scores and the banned hosts, you can limit the memory usage using the `entries_soft_limit` and `entries_hard_limit` configuration keys.
 
-The REST API allows:
+Using the REST API you can:
 
-- to retrieve the score for an IP address
-- to retrieve the ban time for an IP address
-- to unban an IP address
-
-We don't return the whole list of the banned IP addresses or all stored scores because we store them as a hash map and iterating over all the keys of a hash map is not a fast operation and will slow down the recordings of new events.
+- list hosts within the defender's lists
+- remove hosts from the defender's lists
 
 The `defender` can also load a permanent block list and/or a safe list of ip addresses/networks from a file:
 

+ 6 - 1
httpd/api_defender.go

@@ -14,7 +14,12 @@ import (
 )
 
 func getDefenderHosts(w http.ResponseWriter, r *http.Request) {
-	render.JSON(w, r, common.GetDefenderHosts())
+	hosts := common.GetDefenderHosts()
+	if hosts == nil {
+		render.JSON(w, r, make([]common.DefenderEntry, 0))
+		return
+	}
+	render.JSON(w, r, hosts)
 }
 
 func getDefenderHostByID(w http.ResponseWriter, r *http.Request) {

+ 6 - 0
httpd/httpd.go

@@ -82,6 +82,8 @@ const (
 	webChangeAdminPwdPathDefault    = "/web/admin/changepwd"
 	webTemplateUserDefault          = "/web/admin/template/user"
 	webTemplateFolderDefault        = "/web/admin/template/folder"
+	webDefenderPathDefault          = "/web/admin/defender"
+	webDefenderHostsPathDefault     = "/web/admin/defender/hosts"
 	webClientLoginPathDefault       = "/web/client/login"
 	webClientFilesPathDefault       = "/web/client/files"
 	webClientDirContentsPathDefault = "/web/client/listdir"
@@ -128,6 +130,8 @@ var (
 	webChangeAdminPwdPath    string
 	webTemplateUser          string
 	webTemplateFolder        string
+	webDefenderPath          string
+	webDefenderHostsPath     string
 	webClientLoginPath       string
 	webClientFilesPath       string
 	webClientDirContentsPath string
@@ -460,6 +464,8 @@ func updateWebAdminURLs(baseURL string) {
 	webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
 	webTemplateUser = path.Join(baseURL, webTemplateUserDefault)
 	webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault)
+	webDefenderHostsPath = path.Join(baseURL, webDefenderHostsPathDefault)
+	webDefenderPath = path.Join(baseURL, webDefenderPathDefault)
 	webStaticFilesPath = path.Join(baseURL, webStaticFilesPathDefault)
 }
 

+ 21 - 0
httpd/httpd_test.go

@@ -99,6 +99,7 @@ const (
 	webChangeAdminPwdPath           = "/web/admin/changepwd"
 	webTemplateUser                 = "/web/admin/template/user"
 	webTemplateFolder               = "/web/admin/template/folder"
+	webDefenderPath                 = "/web/admin/defender"
 	webBasePathClient               = "/web/client"
 	webClientLoginPath              = "/web/client/login"
 	webClientFilesPath              = "/web/client/files"
@@ -3842,6 +3843,8 @@ func TestChangeAdminPwdMock(t *testing.T) {
 func TestUpdateAdminMock(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
+	_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
+	assert.Error(t, err)
 	admin := getTestAdmin()
 	admin.Username = altAdminUsername
 	admin.Permissions = []string{dataprovider.PermAdminManageAdmins}
@@ -3851,6 +3854,9 @@ func TestUpdateAdminMock(t *testing.T) {
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusCreated, rr)
+	_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
+	assert.NoError(t, err)
+
 	req, _ = http.NewRequest(http.MethodPut, path.Join(adminPath, "abc"), bytes.NewBuffer(asJSON))
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
@@ -3885,6 +3891,7 @@ func TestUpdateAdminMock(t *testing.T) {
 
 	altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
 	assert.NoError(t, err)
+	admin.Password = "" // it must remain unchanged
 	admin.Permissions = []string{dataprovider.PermAdminManageAdmins, dataprovider.PermAdminCloseConnections}
 	asJSON, err = json.Marshal(admin)
 	assert.NoError(t, err)
@@ -3893,6 +3900,9 @@ func TestUpdateAdminMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
+	_, err = getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
+	assert.NoError(t, err)
+
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(adminPath, altAdminUsername), nil)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
@@ -6241,6 +6251,17 @@ func TestBasicWebUsersMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 }
 
+func TestRenderDefenderPageMock(t *testing.T) {
+	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, webDefenderPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "View and manage blocklist")
+}
+
 func TestWebAdminBasicMock(t *testing.T) {
 	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)

+ 3 - 0
httpd/server.go

@@ -742,6 +742,9 @@ func (s *httpdServer) initializeRouter() {
 			router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
 				Get(webTemplateFolder, handleWebTemplateFolderGet)
 			router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, handleWebTemplateFolderPost)
+			router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderPath, handleWebDefenderPage)
+			router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts)
+			router.With(checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}", deleteDefenderHostByID)
 		})
 	}
 }

+ 28 - 29
httpd/webadmin.go

@@ -52,6 +52,7 @@ const (
 	templateMessage      = "message.html"
 	templateStatus       = "status.html"
 	templateLogin        = "login.html"
+	templateDefender     = "defender.html"
 	templateChangePwd    = "changepwd.html"
 	templateMaintenance  = "maintenance.html"
 	templateSetup        = "adminsetup.html"
@@ -62,6 +63,7 @@ const (
 	pageFoldersTitle     = "Folders"
 	pageChangePwdTitle   = "Change password"
 	pageMaintenanceTitle = "Maintenance"
+	pageDefenderTitle    = "Defender"
 	pageSetupTitle       = "Create first admin user"
 	defaultQueryLimit    = 500
 )
@@ -83,6 +85,7 @@ type basePage struct {
 	FoldersURL         string
 	FolderURL          string
 	FolderTemplateURL  string
+	DefenderURL        string
 	LogoutURL          string
 	ChangeAdminPwdURL  string
 	FolderQuotaScanURL string
@@ -95,8 +98,10 @@ type basePage struct {
 	FoldersTitle       string
 	StatusTitle        string
 	MaintenanceTitle   string
+	DefenderTitle      string
 	Version            string
 	CSRFToken          string
+	HasDefender        bool
 	LoggedAdmin        *dataprovider.Admin
 }
 
@@ -159,6 +164,11 @@ type maintenancePage struct {
 	Error       string
 }
 
+type defenderHostsPage struct {
+	basePage
+	DefenderHostsURL string
+}
+
 type setupPage struct {
 	basePage
 	Username string
@@ -234,6 +244,10 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
 	}
+	defenderPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateDefender),
+	}
 	setupPath := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateSetup),
 	}
@@ -249,6 +263,7 @@ func loadAdminTemplates(templatesPath string) {
 	loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
 	changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...))
 	maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...))
+	defenderTmpl := utils.LoadTemplate(template.ParseFiles(defenderPath...))
 	setupTmpl := utils.LoadTemplate(template.ParseFiles(setupPath...))
 
 	adminTemplates[templateUsers] = usersTmpl
@@ -263,6 +278,7 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateLogin] = loginTmpl
 	adminTemplates[templateChangePwd] = changePwdTmpl
 	adminTemplates[templateMaintenance] = maintenanceTmpl
+	adminTemplates[templateDefender] = defenderTmpl
 	adminTemplates[templateSetup] = setupTmpl
 }
 
@@ -282,6 +298,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		FoldersURL:         webFoldersPath,
 		FolderURL:          webFolderPath,
 		FolderTemplateURL:  webTemplateFolder,
+		DefenderURL:        webDefenderPath,
 		LogoutURL:          webLogoutPath,
 		ChangeAdminPwdURL:  webChangeAdminPwdPath,
 		QuotaScanURL:       webQuotaScanPath,
@@ -296,8 +313,10 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		FoldersTitle:       pageFoldersTitle,
 		StatusTitle:        pageStatusTitle,
 		MaintenanceTitle:   pageMaintenanceTitle,
+		DefenderTitle:      pageDefenderTitle,
 		Version:            version.GetAsString(),
 		LoggedAdmin:        getAdminFromToken(r),
+		HasDefender:        common.Config.DefenderConfig.Enabled,
 		CSRFToken:          csrfToken,
 	}
 }
@@ -540,35 +559,6 @@ func getVirtualFoldersFromPostFields(r *http.Request) []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{
-						Name: 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
 }
 
@@ -1232,6 +1222,15 @@ func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, webAdminsPath, http.StatusSeeOther)
 }
 
+func handleWebDefenderPage(w http.ResponseWriter, r *http.Request) {
+	data := defenderHostsPage{
+		basePage:         getBasePageData(pageDefenderTitle, webDefenderPath, r),
+		DefenderHostsURL: webDefenderHostsPath,
+	}
+
+	renderAdminTemplate(w, templateDefender, data)
+}
+
 func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
 	limit := defaultQueryLimit
 	if _, ok := r.URL.Query()["qlimit"]; ok {

+ 5 - 3
templates/webadmin/admins.html

@@ -107,16 +107,18 @@
             headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
             timeout: 15000,
             success: function (result) {
-                table.button('delete:name').enable(true);
                 window.location.href = '{{.AdminsURL}}';
             },
             error: function ($xhr, textStatus, errorThrown) {
-                table.button('delete:name').enable(true);
                 var txt = "Unable to delete the selected admin";
                 if ($xhr) {
                     var json = $xhr.responseJSON;
                     if (json) {
-                        txt += ": " + json.error;
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
                     }
                 }
                 $('#errorTxt').text(txt);

+ 8 - 0
templates/webadmin/base.html

@@ -97,6 +97,14 @@
             </li>
             {{end}}
 
+            {{ if and .HasDefender (.LoggedAdmin.HasPermission "view_defender")}}
+            <li class="nav-item {{if eq .CurrentURL .DefenderURL}}active{{end}}">
+                <a class="nav-link" href="{{.DefenderURL}}">
+                    <i class="fas fa-shield-alt"></i>
+                    <span>{{.DefenderTitle}}</span></a>
+            </li>
+            {{end}}
+
             {{ if .LoggedAdmin.HasPermission "manage_admins"}}
             <li class="nav-item {{if eq .CurrentURL .AdminsURL}}active{{end}}">
                 <a class="nav-link" href="{{.AdminsURL}}">

+ 5 - 3
templates/webadmin/connections.html

@@ -100,17 +100,19 @@
             timeout: 15000,
             success: function (result) {
                 setTimeout(function () {
-                    table.button('disconnect:name').enable(true);
                     window.location.href = '{{.ConnectionsURL}}';
                 }, 1000);
             },
             error: function ($xhr, textStatus, errorThrown) {
-                table.button('disconnect:name').enable(true);
                 var txt = "Failed to close the selected connection";
                 if ($xhr) {
                     var json = $xhr.responseJSON;
                     if (json) {
-                        txt += ": " + json.message;
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
                     }
                 }
                 $('#errorTxt').text(txt);

+ 213 - 0
templates/webadmin/defender.html

@@ -0,0 +1,213 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
+    <div id="errorTxt" class="card-body text-form-error"></div>
+</div>
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">View and manage blocklist</h6>
+    </div>
+    <div class="card-body">
+        <div class="table-responsive">
+            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>ID</th>
+                        <th>IP</th>
+                        <th>Ban time</th>
+                        <th>Score</th>
+                    </tr>
+                </thead>
+            </table>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="deleteModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">×</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to remoce the selected blocklist entry?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="deleteAction()">
+                    Delete
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
+<script type="text/javascript">
+
+function deleteAction() {
+        var table = $('#dataTable').DataTable();
+        table.button('delete:name').enable(false);
+        var id = table.row({ selected: true }).data()["id"];
+        var path = '{{.DefenderHostsURL}}' + "/" + fixedEncodeURIComponent(id);
+        $('#deleteModal').modal('hide');
+        $.ajax({
+            url: path,
+            type: 'DELETE',
+            dataType: 'json',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                window.location.href = '{{.DefenderURL}}';
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Unable to delete the selected entry";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTxt').text(txt);
+                $('#errorMsg').show();
+                setTimeout(function () {
+                    $('#errorMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    $(document).ready(function () {
+        $.fn.dataTable.ext.buttons.refresh = {
+            text: '<i class="fas fa-sync-alt"></i>',
+            name: 'refresh',
+            titleAttr: "Refresh",
+            action: function (e, dt, node, config) {
+                location.reload();
+            }
+        };
+
+        $.fn.dataTable.ext.buttons.delete = {
+            text: '<i class="fas fa-trash"></i>',
+            name: 'delete',
+            titleAttr: "Delete",
+            action: function (e, dt, node, config) {
+                $('#deleteModal').modal('show');
+            },
+            enabled: false
+        };
+
+        var table = $('#dataTable').DataTable({
+            "ajax": {
+                "url": "{{.DefenderHostsURL}}",
+                "dataSrc": "",
+                "error": function ($xhr, textStatus, errorThrown) {
+                    $(".dataTables_processing").hide();
+                    var txt = "Failed to get defender's list";
+                    if ($xhr) {
+                        var json = $xhr.responseJSON;
+                        if (json) {
+                            if (json.message){
+                                txt += ": " + json.message;
+                            } else {
+                                txt += ": " + json.error;
+                            }
+                        }
+                    }
+                    $('#errorTxt').text(txt);
+                    $('#errorMsg').show();
+                    setTimeout(function () {
+                        $('#errorMsg').hide();
+                    }, 10000);
+                }
+            },
+            "deferRender": true,
+            "processing": true,
+            "columns": [
+                { "data": "id" },
+                { "data": "ip" },
+                {
+                    "data": "ban_time",
+                    "defaultContent": ""
+                },
+                {
+                    "data": "score",
+                    "defaultContent": ""
+                }
+            ],
+            "select": {
+                "style": "single",
+                "blurable": true
+            },
+            "buttons": [],
+            "lengthChange": false,
+            "columnDefs": [
+                {
+                    "targets": [0],
+                    "visible": false,
+                    "searchable": false
+                },
+            ],
+            "scrollX": false,
+            "scrollY": false,
+            "responsive": true,
+            "language": {
+                "processing": '<i class="fas fa-spinner fa-spin fa-3x fa-fw"></i><span class="sr-only">Loading...</span>',
+                "loadingRecords": "",
+                "emptyTable": "No records found"
+            },
+            "initComplete": function (settings, json) {
+                {{if .LoggedAdmin.HasPermission "manage_defender"}}
+                table.button().add(0, 'delete');
+                {{end}}
+                table.button().add(0, 'pageLength');
+                table.button().add(0, 'refresh');
+                table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
+            },
+            "order": [[2, 'desc'],[3,'desc']]
+        });
+
+        new $.fn.dataTable.FixedHeader(table);
+        $.fn.dataTable.ext.errMode = 'none';
+
+        {{if .LoggedAdmin.HasPermission "manage_defender"}}
+        table.on('select deselect', function () {
+            var selectedRows = table.rows({ selected: true }).count();
+            table.button('delete:name').enable(selectedRows == 1);
+        });
+        {{end}}
+    });
+</script>
+{{end}}

+ 5 - 3
templates/webadmin/folders.html

@@ -105,16 +105,18 @@ function deleteAction() {
             headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
             timeout: 15000,
             success: function (result) {
-                table.button('delete:name').enable(true);
                 window.location.href = '{{.FoldersURL}}';
             },
             error: function ($xhr, textStatus, errorThrown) {
-                table.button('delete:name').enable(true);
                 var txt = "Unable to delete the selected folder";
                 if ($xhr) {
                     var json = $xhr.responseJSON;
                     if (json) {
-                        txt += ": " + json.error;
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
                     }
                 }
                 $('#errorTxt').text(txt);

+ 5 - 3
templates/webadmin/users.html

@@ -108,16 +108,18 @@
             headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
             timeout: 15000,
             success: function (result) {
-                table.button('delete:name').enable(true);
                 window.location.href = '{{.UsersURL}}';
             },
             error: function ($xhr, textStatus, errorThrown) {
-                table.button('delete:name').enable(true);
                 var txt = "Unable to delete the selected user";
                 if ($xhr) {
                     var json = $xhr.responseJSON;
                     if (json) {
-                        txt += ": " + json.error;
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
                     }
                 }
                 $('#errorTxt').text(txt);

+ 5 - 1
templates/webclient/files.html

@@ -197,7 +197,11 @@
                     if ($xhr) {
                         var json = $xhr.responseJSON;
                         if (json) {
-                            txt += ": " + json.message;
+                            if (json.message){
+                                txt += ": " + json.message;
+                            } else {
+                                txt += ": " + json.error;
+                            }
                         }
                     }
                     $('#errorTxt').text(txt);