From 45e5f06b86c66d30ece0cf9169d3a5b65a87e380 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Tue, 10 Dec 2024 21:52:12 +0100 Subject: [PATCH] Some Backend Admin fixes and updates (#5272) * Some Backend Admin fixes and updates - Updated datatables - Added a `X-Robots-Tags` header to prevent indexing - Modified some layout settings - Added Websocket check to diagnostics - Added Security Header checks to diagnostics - Added Error page response checks to diagnostics - Modifed support string layout a bit Signed-off-by: BlackDex * Some small fixes Signed-off-by: BlackDex --------- Signed-off-by: BlackDex --- src/api/admin.rs | 7 + src/static/scripts/admin.css | 4 +- src/static/scripts/admin_diagnostics.js | 212 ++- src/static/scripts/datatables.css | 42 +- src/static/scripts/datatables.js | 1414 +++++++++++++------- src/static/templates/admin/diagnostics.hbs | 23 + src/static/templates/admin/users.hbs | 2 +- src/util.rs | 2 + 8 files changed, 1194 insertions(+), 512 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index cc902e39..45eb5972 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -62,6 +62,7 @@ pub fn routes() -> Vec { diagnostics, get_diagnostics_config, resend_user_invite, + get_diagnostics_http, ] } @@ -713,6 +714,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) "ip_header_name": ip_header_name, "ip_header_config": &CONFIG.ip_header(), "uses_proxy": uses_proxy, + "enable_websocket": &CONFIG.enable_websocket(), "db_type": *DB_TYPE, "db_version": get_sql_server_version(&mut conn).await, "admin_url": format!("{}/diagnostics", admin_url()), @@ -734,6 +736,11 @@ fn get_diagnostics_config(_token: AdminToken) -> Json { Json(support_json) } +#[get("/diagnostics/http?")] +fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult { + err_code!(format!("Testing error {code} response"), code); +} + #[post("/config", data = "")] fn post_config(data: Json, _token: AdminToken) -> EmptyResult { let data: ConfigBuilder = data.into_inner(); diff --git a/src/static/scripts/admin.css b/src/static/scripts/admin.css index 1db8d4c0..ee035ac4 100644 --- a/src/static/scripts/admin.css +++ b/src/static/scripts/admin.css @@ -38,8 +38,8 @@ img { max-width: 130px; } #users-table .vw-actions, #orgs-table .vw-actions { - min-width: 130px; - max-width: 130px; + min-width: 135px; + max-width: 140px; } #users-table .vw-org-cell { max-height: 120px; diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index 6a178e4b..258df5e1 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -7,6 +7,8 @@ var timeCheck = false; var ntpTimeCheck = false; var domainCheck = false; var httpsCheck = false; +var websocketCheck = false; +var httpResponseCheck = false; // ================================ // Date & Time Check @@ -76,18 +78,15 @@ async function generateSupportString(event, dj) { event.preventDefault(); event.stopPropagation(); - let supportString = "### Your environment (Generated via diagnostics page)\n"; + let supportString = "### Your environment (Generated via diagnostics page)\n\n"; supportString += `* Vaultwarden version: v${dj.current_release}\n`; supportString += `* Web-vault version: v${dj.web_vault_version}\n`; supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`; - supportString += "* Environment settings overridden: "; - if (dj.overrides != "") { - supportString += "true\n"; - } else { - supportString += "false\n"; - } + supportString += `* Database type: ${dj.db_type}\n`; + supportString += `* Database version: ${dj.db_version}\n`; + supportString += `* Environment settings overridden!: ${dj.overrides !== ""}\n`; supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; if (dj.ip_header_exists) { supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; @@ -99,11 +98,12 @@ async function generateSupportString(event, dj) { supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; supportString += `* Domain Configuration Check: ${domainCheck}\n`; supportString += `* HTTPS Check: ${httpsCheck}\n`; - supportString += `* Database type: ${dj.db_type}\n`; - supportString += `* Database version: ${dj.db_version}\n`; - supportString += "* Clients used: \n"; - supportString += "* Reverse proxy and version: \n"; - supportString += "* Other relevant information: \n"; + if (dj.enable_websocket) { + supportString += `* Websocket Check: ${websocketCheck}\n`; + } else { + supportString += "* Websocket Check: disabled\n"; + } + supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { "headers": { "Accept": "application/json" } @@ -113,10 +113,30 @@ async function generateSupportString(event, dj) { throw new Error(jsonResponse); } const configJson = await jsonResponse.json(); - supportString += "\n### Config (Generated via diagnostics page)\n
Show Running Config\n"; - supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; - supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n
\n"; + // Start Config and Details section within a details block which is collapsed by default + supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; + supportString += "
Show Config & Details\n"; + + // Add overrides if they exists + if (dj.overrides != "") { + supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; + } + + // Add http response check messages if they exists + if (httpResponseCheck === false) { + supportString += "\n**Failed HTTP Checks:**\n"; + // We use `innerText` here since that will convert
into new-lines + supportString += "\n```yaml\n" + document.getElementById("http-response-errors").innerText.trim() + "\n```\n"; + } + + // Add the current config in json form + supportString += "\n**Config:**\n"; + supportString += "\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n"; + + supportString += "\n
\n"; + + // Add the support string to the textbox so it can be viewed and copied document.getElementById("support-string").textContent = supportString; document.getElementById("support-string").classList.remove("d-none"); document.getElementById("copy-support").classList.remove("d-none"); @@ -199,6 +219,162 @@ function checkDns(dns_resolved) { } } +async function fetchCheckUrl(url) { + try { + const response = await fetch(url); + return { headers: response.headers, status: response.status, text: await response.text() }; + } catch (error) { + console.error(`Error fetching ${url}: ${error}`); + return { error }; + } +} + +function checkSecurityHeaders(headers, omit) { + let securityHeaders = { + "x-frame-options": ["SAMEORIGIN"], + "x-content-type-options": ["nosniff"], + "referrer-policy": ["same-origin"], + "x-xss-protection": ["0"], + "x-robots-tag": ["noindex", "nofollow"], + "content-security-policy": [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'self' blob:", + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "child-src 'self' https://*.duosecurity.com https://*.duofederal.com", + "frame-src 'self' https://*.duosecurity.com https://*.duofederal.com", + "frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh moz-extension://*", + "img-src 'self' data: https://haveibeenpwned.com", + "connect-src 'self' https://api.pwnedpasswords.com https://api.2fa.directory https://app.simplelogin.io/api/ https://app.addy.io/api/ https://api.fastmail.com/ https://api.forwardemail.net", + ] + }; + + let messages = []; + for (let header in securityHeaders) { + // Skip some headers for specific endpoints if needed + if (typeof omit === "object" && omit.includes(header) === true) { + continue; + } + // If the header exists, check if the contents matches what we expect it to be + let headerValue = headers.get(header); + if (headerValue !== null) { + securityHeaders[header].forEach((expectedValue) => { + if (headerValue.indexOf(expectedValue) === -1) { + messages.push(`'${header}' does not contain '${expectedValue}'`); + } + }); + } else { + messages.push(`'${header}' is missing!`); + } + } + return messages; +} + +async function checkHttpResponse() { + const [apiConfig, webauthnConnector, notFound, notFoundApi, badRequest, unauthorized, forbidden] = await Promise.all([ + fetchCheckUrl(`${BASE_URL}/api/config`), + fetchCheckUrl(`${BASE_URL}/webauthn-connector.html`), + fetchCheckUrl(`${BASE_URL}/admin/does-not-exist`), + fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=404`), + fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=400`), + fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=401`), + fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=403`), + ]); + + const respErrorElm = document.getElementById("http-response-errors"); + + // Check and validate the default API header responses + let apiErrors = checkSecurityHeaders(apiConfig.headers); + if (apiErrors.length >= 1) { + respErrorElm.innerHTML += "API calls:
"; + apiErrors.forEach((errMsg) => { + respErrorElm.innerHTML += `Header: ${errMsg}
`; + }); + } + + // Check the special `-connector.html` headers, these should have some headers omitted. + const omitConnectorHeaders = ["x-frame-options", "content-security-policy"]; + let connectorErrors = checkSecurityHeaders(webauthnConnector.headers, omitConnectorHeaders); + omitConnectorHeaders.forEach((header) => { + if (webauthnConnector.headers.get(header) !== null) { + connectorErrors.push(`'${header}' is present while it should not`); + } + }); + if (connectorErrors.length >= 1) { + respErrorElm.innerHTML += "2FA Connector calls:
"; + connectorErrors.forEach((errMsg) => { + respErrorElm.innerHTML += `Header: ${errMsg}
`; + }); + } + + // Check specific error code responses if they are not re-written by a reverse proxy + let responseErrors = []; + if (notFound.status !== 404 || notFound.text.indexOf("return to the web-vault") === -1) { + responseErrors.push("404 (Not Found) HTML is invalid"); + } + + if (notFoundApi.status !== 404 || notFoundApi.text.indexOf("\"message\":\"Testing error 404 response\",") === -1) { + responseErrors.push("404 (Not Found) JSON is invalid"); + } + + if (badRequest.status !== 400 || badRequest.text.indexOf("\"message\":\"Testing error 400 response\",") === -1) { + responseErrors.push("400 (Bad Request) is invalid"); + } + + if (unauthorized.status !== 401 || unauthorized.text.indexOf("\"message\":\"Testing error 401 response\",") === -1) { + responseErrors.push("401 (Unauthorized) is invalid"); + } + + if (forbidden.status !== 403 || forbidden.text.indexOf("\"message\":\"Testing error 403 response\",") === -1) { + responseErrors.push("403 (Forbidden) is invalid"); + } + + if (responseErrors.length >= 1) { + respErrorElm.innerHTML += "HTTP error responses:
"; + responseErrors.forEach((errMsg) => { + respErrorElm.innerHTML += `Response to: ${errMsg}
`; + }); + } + + if (responseErrors.length >= 1 || connectorErrors.length >= 1 || apiErrors.length >= 1) { + document.getElementById("http-response-warning").classList.remove("d-none"); + } else { + httpResponseCheck = true; + document.getElementById("http-response-success").classList.remove("d-none"); + } +} + +async function fetchWsUrl(wsUrl) { + return new Promise((resolve, reject) => { + try { + const ws = new WebSocket(wsUrl); + ws.onopen = () => { + ws.close(); + resolve(true); + }; + + ws.onerror = () => { + reject(false); + }; + } catch (_) { + reject(false); + } + }); +} + +async function checkWebsocketConnection() { + // Test Websocket connections via the anonymous (login with device) connection + const isConnected = await fetchWsUrl(`${BASE_URL}/notifications/anonymous-hub?token=admin-diagnostics`).catch(() => false); + if (isConnected) { + websocketCheck = true; + document.getElementById("websocket-success").classList.remove("d-none"); + } else { + document.getElementById("websocket-error").classList.remove("d-none"); + } +} + function init(dj) { // Time check document.getElementById("time-browser-string").textContent = browserUTC; @@ -225,6 +401,12 @@ function init(dj) { // DNS Check checkDns(dj.dns_resolved); + + checkHttpResponse(); + + if (dj.enable_websocket) { + checkWebsocketConnection(); + } } // onLoad events diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 878e2347..195caa84 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,10 +4,10 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-2.0.8 + * https://datatables.net/download/#bs5/dt-2.1.8 * * Included libraries: - * DataTables 2.0.8 + * DataTables 2.1.8 */ @charset "UTF-8"; @@ -45,15 +45,21 @@ table.dataTable tr.dt-hasChild td.dt-control:before { } html.dark table.dataTable td.dt-control:before, -:root[data-bs-theme=dark] table.dataTable td.dt-control:before { +:root[data-bs-theme=dark] table.dataTable td.dt-control:before, +:root[data-theme=dark] table.dataTable td.dt-control:before { border-left-color: rgba(255, 255, 255, 0.5); } html.dark table.dataTable tr.dt-hasChild td.dt-control:before, -:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before { +:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before, +:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before { border-top-color: rgba(255, 255, 255, 0.5); border-left-color: transparent; } +div.dt-scroll { + width: 100%; +} + div.dt-scroll-body thead tr, div.dt-scroll-body tfoot tr { height: 0; @@ -377,6 +383,31 @@ table.table.dataTable.table-hover > tbody > tr.selected:hover > * { box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975); } +div.dt-container div.dt-layout-start > *:not(:last-child) { + margin-right: 1em; +} +div.dt-container div.dt-layout-end > *:not(:first-child) { + margin-left: 1em; +} +div.dt-container div.dt-layout-full { + width: 100%; +} +div.dt-container div.dt-layout-full > *:only-child { + margin-left: auto; + margin-right: auto; +} +div.dt-container div.dt-layout-table > div { + display: block !important; +} + +@media screen and (max-width: 767px) { + div.dt-container div.dt-layout-start > *:not(:last-child) { + margin-right: 0; + } + div.dt-container div.dt-layout-end > *:not(:first-child) { + margin-left: 0; + } +} div.dt-container div.dt-length label { font-weight: normal; text-align: left; @@ -400,9 +431,6 @@ div.dt-container div.dt-search input { display: inline-block; width: auto; } -div.dt-container div.dt-info { - padding-top: 0.85em; -} div.dt-container div.dt-paging { margin: 0; } diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 3d22cbde..d0361b54 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,20 +4,20 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-2.0.8 + * https://datatables.net/download/#bs5/dt-2.1.8 * * Included libraries: - * DataTables 2.0.8 + * DataTables 2.1.8 */ -/*! DataTables 2.0.8 +/*! DataTables 2.1.8 * © SpryMedia Ltd - datatables.net/license */ /** * @summary DataTables * @description Paginate, search and order HTML tables - * @version 2.0.8 + * @version 2.1.8 * @author SpryMedia Ltd * @contact www.datatables.net * @copyright SpryMedia Ltd. @@ -116,7 +116,6 @@ var i=0, iLen; var sId = this.getAttribute( 'id' ); - var bInitHandedOff = false; var defaults = DataTable.defaults; var $this = $(this); @@ -266,6 +265,8 @@ "rowId", "caption", "layout", + "orderDescReverse", + "typeDetect", [ "iCookieDuration", "iStateDuration" ], // backwards compat [ "oSearch", "oPreviousSearch" ], [ "aoSearchCols", "aoPreSearchCols" ], @@ -312,38 +313,14 @@ oSettings._iDisplayStart = oInit.iDisplayStart; } - /* Language definitions */ - var oLanguage = oSettings.oLanguage; - $.extend( true, oLanguage, oInit.oLanguage ); - - if ( oLanguage.sUrl ) + var defer = oInit.iDeferLoading; + if ( defer !== null ) { - /* Get the language definitions from a file - because this Ajax call makes the language - * get async to the remainder of this function we use bInitHandedOff to indicate that - * _fnInitialise will be fired by the returned Ajax handler, rather than the constructor - */ - $.ajax( { - dataType: 'json', - url: oLanguage.sUrl, - success: function ( json ) { - _fnCamelToHungarian( defaults.oLanguage, json ); - $.extend( true, oLanguage, json, oSettings.oInit.oLanguage ); + oSettings.deferLoading = true; - _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); - _fnInitialise( oSettings ); - }, - error: function () { - // Error occurred loading language file - _fnLog( oSettings, 0, 'i18n file loading error', 21 ); - - // continue on as best we can - _fnInitialise( oSettings ); - } - } ); - bInitHandedOff = true; - } - else { - _fnCallbackFire( oSettings, null, 'i18n', [oSettings]); + var tmp = Array.isArray(defer); + oSettings._iRecordsDisplay = tmp ? defer[0] : defer; + oSettings._iRecordsTotal = tmp ? defer[1] : defer; } /* @@ -410,113 +387,112 @@ } ); } - var features = oSettings.oFeatures; - var loadedInit = function () { - /* - * Sorting - * @todo For modularisation (1.11) this needs to do into a sort start up handler - */ - - // If aaSorting is not defined, then we use the first indicator in asSorting - // in case that has been altered, so the default sort reflects that option - if ( oInit.aaSorting === undefined ) { - var sorting = oSettings.aaSorting; - for ( i=0, iLen=sorting.length ; i').appendTo( $this ); - } - - caption.html( oSettings.caption ); - } - - // Store the caption side, so we can remove the element from the document - // when creating the element - if (caption.length) { - caption[0]._captionSide = caption.css('caption-side'); - oSettings.captionNode = caption[0]; - } - - if ( thead.length === 0 ) { - thead = $('').appendTo($this); - } - oSettings.nTHead = thead[0]; - $('tr', thead).addClass(oClasses.thead.row); - - var tbody = $this.children('tbody'); - if ( tbody.length === 0 ) { - tbody = $('').insertAfter(thead); - } - oSettings.nTBody = tbody[0]; - - var tfoot = $this.children('tfoot'); - if ( tfoot.length === 0 ) { - // If we are a scrolling table, and no footer has been given, then we need to create - // a tfoot element for the caption element to be appended to - tfoot = $('').appendTo($this); - } - oSettings.nTFoot = tfoot[0]; - $('tr', tfoot).addClass(oClasses.tfoot.row); - - // Check if there is data passing into the constructor - if ( oInit.aaData ) { - for ( i=0 ; i').appendTo( $this ); + } + + caption.html( oSettings.caption ); + } + + // Store the caption side, so we can remove the element from the document + // when creating the element + if (caption.length) { + caption[0]._captionSide = caption.css('caption-side'); + oSettings.captionNode = caption[0]; + } + + if ( thead.length === 0 ) { + thead = $('').appendTo($this); + } + oSettings.nTHead = thead[0]; + $('tr', thead).addClass(oClasses.thead.row); + + var tbody = $this.children('tbody'); + if ( tbody.length === 0 ) { + tbody = $('').insertAfter(thead); + } + oSettings.nTBody = tbody[0]; + + var tfoot = $this.children('tfoot'); + if ( tfoot.length === 0 ) { + // If we are a scrolling table, and no footer has been given, then we need to create + // a tfoot element for the caption element to be appended to + tfoot = $('').appendTo($this); + } + oSettings.nTFoot = tfoot[0]; + $('tr', tfoot).addClass(oClasses.tfoot.row); + + // Copy the data index array + oSettings.aiDisplay = oSettings.aiDisplayMaster.slice(); + + // Initialisation complete - table can be drawn + oSettings.bInitialised = true; + + // Language definitions + var oLanguage = oSettings.oLanguage; + $.extend( true, oLanguage, oInit.oLanguage ); + + if ( oLanguage.sUrl ) { + // Get the language definitions from a file + $.ajax( { + dataType: 'json', + url: oLanguage.sUrl, + success: function ( json ) { + _fnCamelToHungarian( defaults.oLanguage, json ); + $.extend( true, oLanguage, json, oSettings.oInit.oLanguage ); + + _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); + _fnInitialise( oSettings ); + }, + error: function () { + // Error occurred loading language file + _fnLog( oSettings, 0, 'i18n file loading error', 21 ); + + // Continue on as best we can + _fnInitialise( oSettings ); + } + } ); + } + else { + _fnCallbackFire( oSettings, null, 'i18n', [oSettings], true); + _fnInitialise( oSettings ); + } } ); _that = null; return this; @@ -563,7 +539,7 @@ * * @type string */ - builder: "bs5/dt-2.0.8", + builder: "bs5/dt-2.1.8", /** @@ -1033,6 +1009,15 @@ info: { container: 'dt-info' }, + layout: { + row: 'dt-layout-row', + cell: 'dt-layout-cell', + tableRow: 'dt-layout-table', + tableCell: '', + start: 'dt-layout-start', + end: 'dt-layout-end', + full: 'dt-layout-full' + }, length: { container: 'dt-length', select: 'dt-input' @@ -1081,7 +1066,8 @@ active: 'current', button: 'dt-paging-button', container: 'dt-paging', - disabled: 'disabled' + disabled: 'disabled', + nav: '' } } ); @@ -1156,7 +1142,7 @@ }; - var _isNumber = function ( d, decimalPoint, formatted ) { + var _isNumber = function ( d, decimalPoint, formatted, allowEmpty ) { var type = typeof d; var strType = type === 'string'; @@ -1167,7 +1153,7 @@ // If empty return immediately so there must be a number if it is a // formatted string (this stops the string "k", or "kr", etc being detected // as a formatted number for currency - if ( _empty( d ) ) { + if ( allowEmpty && _empty( d ) ) { return true; } @@ -1189,8 +1175,8 @@ }; // Is a string a number surrounded by HTML? - var _htmlNumeric = function ( d, decimalPoint, formatted ) { - if ( _empty( d ) ) { + var _htmlNumeric = function ( d, decimalPoint, formatted, allowEmpty ) { + if ( allowEmpty && _empty( d ) ) { return true; } @@ -1202,7 +1188,7 @@ var html = _isHtml( d ); return ! html ? null : - _isNumber( _stripHtml( d ), decimalPoint, formatted ) ? + _isNumber( _stripHtml( d ), decimalPoint, formatted, allowEmpty ) ? true : null; }; @@ -1244,7 +1230,7 @@ // is essential here if ( prop2 !== undefined ) { for ( ; i _max_str_len) { throw new Error('Exceeded max str len'); @@ -1340,8 +1330,11 @@ } // It is faster to just run `normalize` than it is to check if - // we need to with a regex! - var res = str.normalize("NFD"); + // we need to with a regex! (Check as it isn't available in old + // Safari) + var res = str.normalize + ? str.normalize("NFD") + : str; // Equally, here we check if a regex is needed or not return res.length !== str.length @@ -2264,6 +2257,21 @@ return a; } + /** + * Allow the result from a type detection function to be `true` while + * translating that into a string. Old type detection functions will + * return the type name if it passes. An obect store would be better, + * but not backwards compatible. + * + * @param {*} typeDetect Object or function for type detection + * @param {*} res Result from the type detection function + * @returns Type name or false + */ + function _typeResult (typeDetect, res) { + return res === true + ? typeDetect._name + : res; + } /** * Calculate the 'type' of a column @@ -2278,7 +2286,7 @@ var i, ien, j, jen, k, ken; var col, detectedType, cache; - // For each column, spin over the + // For each column, spin over the data type detection functions, seeing if one matches for ( i=0, ien=columns.length ; i col is set to and correct if needed - for (var i=0 ; i col is set to and correct if needed + for (var i=0 ; i 0 ? idx : null; + } + + // `:visible` on its own + return idx; } ); case 'name': @@ -9215,23 +9404,60 @@ } ); /** - * Set the jQuery or window object to be used by DataTables - * - * @param {*} module Library / container object - * @param {string} [type] Library or container type `lib`, `win` or `datetime`. - * If not provided, automatic detection is attempted. + * Set the libraries that DataTables uses, or the global objects. + * Note that the arguments can be either way around (legacy support) + * and the second is optional. See docs. */ - DataTable.use = function (module, type) { - if (type === 'lib' || module.fn) { + DataTable.use = function (arg1, arg2) { + // Reverse arguments for legacy support + var module = typeof arg1 === 'string' + ? arg2 + : arg1; + var type = typeof arg2 === 'string' + ? arg2 + : arg1; + + // Getter + if (module === undefined && typeof type === 'string') { + switch (type) { + case 'lib': + case 'jq': + return $; + + case 'win': + return window; + + case 'datetime': + return DataTable.DateTime; + + case 'luxon': + return __luxon; + + case 'moment': + return __moment; + + default: + return null; + } + } + + // Setter + if (type === 'lib' || type === 'jq' || (module && module.fn && module.fn.jquery)) { $ = module; } - else if (type == 'win' || module.document) { + else if (type == 'win' || (module && module.document)) { window = module; document = module.document; } - else if (type === 'datetime' || module.type === 'DateTime') { + else if (type === 'datetime' || (module && module.type === 'DateTime')) { DataTable.DateTime = module; } + else if (type === 'luxon' || (module && module.FixedOffsetZone)) { + __luxon = module; + } + else if (type === 'moment' || (module && module.isMoment)) { + __moment = module; + } } /** @@ -9487,7 +9713,7 @@ fn.call(this); } else { - this.on('init', function () { + this.on('init.dt.DT', function () { fn.call(this); }); } @@ -9640,7 +9866,7 @@ * @type string * @default Version number */ - DataTable.version = "2.0.8"; + DataTable.version = "2.1.8"; /** * Private data store, containing all of the settings objects that are @@ -10485,7 +10711,8 @@ first: 'First', last: 'Last', next: 'Next', - previous: 'Previous' + previous: 'Previous', + number: '' } }, @@ -10665,6 +10892,10 @@ }, + /** The initial data order is reversed when `desc` ordering */ + orderDescReverse: true, + + /** * This parameter allows you to have define the global filtering state at * initialisation time. As an object the `search` parameter must be @@ -10713,7 +10944,7 @@ * * `full_numbers` - 'First', 'Previous', 'Next' and 'Last' buttons, plus page numbers * * `first_last_numbers` - 'First' and 'Last' buttons, plus page numbers */ - "sPaginationType": "full_numbers", + "sPaginationType": "", /** @@ -10783,7 +11014,13 @@ /** * Caption value */ - "caption": null + "caption": null, + + + /** + * For server-side processing - use the data from the DOM for the first draw + */ + iDeferLoading: null }; _fnHungarianMap( DataTable.defaults ); @@ -11726,7 +11963,13 @@ captionNode: null, - colgroup: null + colgroup: null, + + /** Delay loading of data */ + deferLoading: null, + + /** Allow auto type detection */ + typeDetect: true }; /** @@ -11750,7 +11993,7 @@ }, full: function () { - return [ 'first', 'previous', 'next', 'last' ]; + return [ 'first', 'previous', 'next', 'last' ]; }, numbers: function () { @@ -11764,11 +12007,11 @@ full_numbers: function () { return [ 'first', 'previous', 'numbers', 'next', 'last' ]; }, - + first_last: function () { return ['first', 'last']; }, - + first_last_numbers: function () { return ['first', 'numbers', 'last']; }, @@ -11850,38 +12093,56 @@ * to make working with DataTables a little bit easier. */ - function __mldFnName(name) { - return name.replace(/[\W]/g, '_') - } - - // Common logic for moment, luxon or a date action - function __mld( dt, momentFn, luxonFn, dateFn, arg1 ) { - if (window.moment) { - return dt[momentFn]( arg1 ); + /** + * Common logic for moment, luxon or a date action. + * + * Happens after __mldObj, so don't need to call `resolveWindowsLibs` again + */ + function __mld( dtLib, momentFn, luxonFn, dateFn, arg1 ) { + if (__moment) { + return dtLib[momentFn]( arg1 ); } - else if (window.luxon) { - return dt[luxonFn]( arg1 ); + else if (__luxon) { + return dtLib[luxonFn]( arg1 ); } - return dateFn ? dt[dateFn]( arg1 ) : dt; + return dateFn ? dtLib[dateFn]( arg1 ) : dtLib; } var __mlWarning = false; + var __luxon; // Can be assigned in DateTeble.use() + var __moment; // Can be assigned in DateTeble.use() + + /** + * + */ + function resolveWindowLibs() { + if (window.luxon && ! __luxon) { + __luxon = window.luxon; + } + + if (window.moment && ! __moment) { + __moment = window.moment; + } + } + function __mldObj (d, format, locale) { var dt; - if (window.moment) { - dt = window.moment.utc( d, format, locale, true ); + resolveWindowLibs(); + + if (__moment) { + dt = __moment.utc( d, format, locale, true ); if (! dt.isValid()) { return null; } } - else if (window.luxon) { + else if (__luxon) { dt = format && typeof d === 'string' - ? window.luxon.DateTime.fromFormat( d, format ) - : window.luxon.DateTime.fromISO( d ); + ? __luxon.DateTime.fromFormat( d, format ) + : __luxon.DateTime.fromISO( d ); if (! dt.isValid) { return null; @@ -11926,11 +12187,11 @@ from = null; } - var typeName = 'datetime' + (to ? '-' + __mldFnName(to) : ''); + var typeName = 'datetime' + (to ? '-' + to : ''); // Add type detection and sorting specific to this date format - we need to be able to identify // date type columns as such, rather than as numbers in extensions. Hence the need for this. - if (! DataTable.ext.type.order[typeName]) { + if (! DataTable.ext.type.order[typeName + '-pre']) { DataTable.type(typeName, { detect: function (d) { // The renderer will give the value to type detect as the type! @@ -12029,7 +12290,7 @@ // Formatted date time detection - use by declaring the formats you are going to use DataTable.datetime = function ( format, locale ) { - var typeName = 'datetime-detect-' + __mldFnName(format); + var typeName = 'datetime-' + format; if (! locale) { locale = 'en'; @@ -12169,7 +12430,7 @@ return { className: _extTypes.className[name], detect: _extTypes.detect.find(function (fn) { - return fn.name === name; + return fn._name === name; }), order: { pre: _extTypes.order[name + '-pre'], @@ -12184,27 +12445,20 @@ var setProp = function(prop, propVal) { _extTypes[prop][name] = propVal; }; - var setDetect = function (fn) { - // Wrap to allow the function to return `true` rather than - // specifying the type name. - var cb = function (d, s) { - var ret = fn(d, s); + var setDetect = function (detect) { + // `detect` can be a function or an object - we set a name + // property for either - that is used for the detection + Object.defineProperty(detect, "_name", {value: name}); - return ret === true - ? name - : ret; - }; - Object.defineProperty(cb, "name", {value: name}); - - var idx = _extTypes.detect.findIndex(function (fn) { - return fn.name === name; + var idx = _extTypes.detect.findIndex(function (item) { + return item._name === name; }); if (idx === -1) { - _extTypes.detect.unshift(cb); + _extTypes.detect.unshift(detect); } else { - _extTypes.detect.splice(idx, 1, cb); + _extTypes.detect.splice(idx, 1, detect); } }; var setOrder = function (obj) { @@ -12260,10 +12514,23 @@ // Get a list of types DataTable.types = function () { return _extTypes.detect.map(function (fn) { - return fn.name; + return fn._name; }); }; + var __diacriticSort = function (a, b) { + a = a !== null && a !== undefined ? a.toString().toLowerCase() : ''; + b = b !== null && b !== undefined ? b.toString().toLowerCase() : ''; + + // Checked for `navigator.languages` support in `oneOf` so this code can't execute in old + // Safari and thus can disable this check + // eslint-disable-next-line compat/compat + return a.localeCompare(b, navigator.languages[0] || navigator.language, { + numeric: true, + ignorePunctuation: true, + }); + } + // // Built in data types // @@ -12276,7 +12543,7 @@ pre: function ( a ) { // This is a little complex, but faster than always calling toString, // http://jsperf.com/tostring-v-check - return _empty(a) ? + return _empty(a) && typeof a !== 'boolean' ? '' : typeof a === 'string' ? a.toLowerCase() : @@ -12288,11 +12555,38 @@ search: _filterString(false, true) }); + DataTable.type('string-utf8', { + detect: { + allOf: function ( d ) { + return true; + }, + oneOf: function ( d ) { + // At least one data point must contain a non-ASCII character + // This line will also check if navigator.languages is supported or not. If not (Safari 10.0-) + // this data type won't be supported. + // eslint-disable-next-line compat/compat + return ! _empty( d ) && navigator.languages && typeof d === 'string' && d.match(/[^\x00-\x7F]/); + } + }, + order: { + asc: __diacriticSort, + desc: function (a, b) { + return __diacriticSort(a, b) * -1; + } + }, + search: _filterString(false, true) + }); + DataTable.type('html', { - detect: function ( d ) { - return _empty( d ) || (typeof d === 'string' && d.indexOf('<') !== -1) ? - 'html' : null; + detect: { + allOf: function ( d ) { + return _empty( d ) || (typeof d === 'string' && d.indexOf('<') !== -1); + }, + oneOf: function ( d ) { + // At least one data point must contain a `<` + return ! _empty( d ) && typeof d === 'string' && d.indexOf('<') !== -1; + } }, order: { pre: function ( a ) { @@ -12309,16 +12603,21 @@ DataTable.type('date', { className: 'dt-type-date', - detect: function ( d ) - { - // V8 tries _very_ hard to make a string passed into `Date.parse()` - // valid, so we need to use a regex to restrict date formats. Use a - // plug-in for anything other than ISO8601 style strings - if ( d && !(d instanceof Date) && ! _re_date.test(d) ) { - return null; + detect: { + allOf: function ( d ) { + // V8 tries _very_ hard to make a string passed into `Date.parse()` + // valid, so we need to use a regex to restrict date formats. Use a + // plug-in for anything other than ISO8601 style strings + if ( d && !(d instanceof Date) && ! _re_date.test(d) ) { + return null; + } + var parsed = Date.parse(d); + return (parsed !== null && !isNaN(parsed)) || _empty(d); + }, + oneOf: function ( d ) { + // At least one entry must be a date or a string with a date + return (d instanceof Date) || (typeof d === 'string' && _re_date.test(d)); } - var parsed = Date.parse(d); - return (parsed !== null && !isNaN(parsed)) || _empty(d) ? 'date' : null; }, order: { pre: function ( d ) { @@ -12331,10 +12630,16 @@ DataTable.type('html-num-fmt', { className: 'dt-type-numeric', - detect: function ( d, settings ) - { - var decimal = settings.oLanguage.sDecimal; - return _htmlNumeric( d, decimal, true ) ? 'html-num-fmt' : null; + detect: { + allOf: function ( d, settings ) { + var decimal = settings.oLanguage.sDecimal; + return _htmlNumeric( d, decimal, true, false ); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.oLanguage.sDecimal; + return _htmlNumeric( d, decimal, true, false ); + } }, order: { pre: function ( d, s ) { @@ -12348,10 +12653,16 @@ DataTable.type('html-num', { className: 'dt-type-numeric', - detect: function ( d, settings ) - { - var decimal = settings.oLanguage.sDecimal; - return _htmlNumeric( d, decimal ) ? 'html-num' : null; + detect: { + allOf: function ( d, settings ) { + var decimal = settings.oLanguage.sDecimal; + return _htmlNumeric( d, decimal, false, true ); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.oLanguage.sDecimal; + return _htmlNumeric( d, decimal, false, false ); + } }, order: { pre: function ( d, s ) { @@ -12365,10 +12676,16 @@ DataTable.type('num-fmt', { className: 'dt-type-numeric', - detect: function ( d, settings ) - { - var decimal = settings.oLanguage.sDecimal; - return _isNumber( d, decimal, true ) ? 'num-fmt' : null; + detect: { + allOf: function ( d, settings ) { + var decimal = settings.oLanguage.sDecimal; + return _isNumber( d, decimal, true, true ); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.oLanguage.sDecimal; + return _isNumber( d, decimal, true, false ); + } }, order: { pre: function ( d, s ) { @@ -12381,10 +12698,16 @@ DataTable.type('num', { className: 'dt-type-numeric', - detect: function ( d, settings ) - { - var decimal = settings.oLanguage.sDecimal; - return _isNumber( d, decimal ) ? 'num' : null; + detect: { + allOf: function ( d, settings ) { + var decimal = settings.oLanguage.sDecimal; + return _isNumber( d, decimal, false, true ); + }, + oneOf: function (d, settings) { + // At least one data point must contain a numeric value + var decimal = settings.oLanguage.sDecimal; + return _isNumber( d, decimal, false, false ); + } }, order: { pre: function (d, s) { @@ -12468,11 +12791,18 @@ // `DT` namespace will allow the event to be removed automatically // on destroy, while the `dt` namespaced event is the one we are // listening for - $(settings.nTable).on( 'order.dt.DT', function ( e, ctx, sorting ) { + $(settings.nTable).on( 'order.dt.DT column-visibility.dt.DT', function ( e, ctx ) { if ( settings !== ctx ) { // need to check this this is the host return; // table, not a nested one } + var sorting = ctx.sortDetails; + + if (! sorting) { + return; + } + + var i; var orderClasses = classes.order; var columns = ctx.api.columns( cell ); var col = settings.aoColumns[columns.flatten()[0]]; @@ -12480,9 +12810,7 @@ var ariaType = ''; var indexes = columns.indexes(); var sortDirs = columns.orderable(true).flatten(); - var orderedColumns = ',' + sorting.map( function (val) { - return val.col; - } ).join(',') + ','; + var orderedColumns = _pluck(sorting, 'col'); cell .removeClass( @@ -12492,10 +12820,18 @@ .toggleClass( orderClasses.none, ! orderable ) .toggleClass( orderClasses.canAsc, orderable && sortDirs.includes('asc') ) .toggleClass( orderClasses.canDesc, orderable && sortDirs.includes('desc') ); - - var sortIdx = orderedColumns.indexOf( ',' + indexes.toArray().join(',') + ',' ); - if ( sortIdx !== -1 ) { + // Determine if all of the columns that this cell covers are included in the + // current ordering + var isOrdering = true; + + for (i=0; i') - .addClass('dt-layout-row') + .attr('id', items.id || null) + .addClass(items.className || classes.row) .appendTo( container ); $.each( items, function (key, val) { - var klass = ! val.table ? - 'dt-'+key+' ' : - ''; + if (key === 'id' || key === 'className') { + return; + } + + var klass = ''; if (val.table) { - row.addClass('dt-layout-table'); + row.addClass(classes.tableRow); + klass += classes.tableCell + ' '; + } + + if (key === 'start') { + klass += classes.start; + } + else if (key === 'end') { + klass += classes.end; + } + else { + klass += classes.full; } $('
') .attr({ id: val.id || null, - "class": 'dt-layout-cell '+klass+(val.className || '') + "class": val.className + ? val.className + : classes.cell + ' ' + klass }) .append( val.contents ) .appendTo( row ); @@ -12576,6 +12941,25 @@ } }; + function _divProp(el, prop, val) { + if (val) { + el[prop] = val; + } + } + + DataTable.feature.register( 'div', function ( settings, opts ) { + var n = $('
')[0]; + + if (opts) { + _divProp(n, 'className', opts.className); + _divProp(n, 'id', opts.id); + _divProp(n, 'innerHTML', opts.html); + _divProp(n, 'textContent', opts.text); + } + + return n; + } ); + DataTable.feature.register( 'info', function ( settings, opts ) { // For compatibility with the legacy `info` top level option if (! settings.oFeatures.bInfo) { @@ -12675,6 +13059,7 @@ opts = $.extend({ placeholder: language.sSearchPlaceholder, + processing: false, text: language.sSearch }, opts); @@ -12718,13 +13103,15 @@ /* Now do the filter */ if ( val != previousSearch.search ) { - previousSearch.search = val; - - _fnFilterComplete( settings, previousSearch ); - - // Need to redraw, without resorting - settings._iDisplayStart = 0; - _fnDraw( settings ); + _fnProcessingRun(settings, opts.processing, function () { + previousSearch.search = val; + + _fnFilterComplete( settings, previousSearch ); + + // Need to redraw, without resorting + settings._iDisplayStart = 0; + _fnDraw( settings ); + }); } }; @@ -12782,17 +13169,21 @@ opts = $.extend({ buttons: DataTable.ext.pager.numbers_length, type: settings.sPaginationType, - boundaryNumbers: true + boundaryNumbers: true, + firstLast: true, + previousNext: true, + numbers: true }, opts); - // To be removed in 2.1 - if (opts.numbers) { - opts.buttons = opts.numbers; - } - - var host = $('
').addClass( settings.oClasses.paging.container + ' paging_' + opts.type ); + var host = $('
') + .addClass(settings.oClasses.paging.container + (opts.type ? ' paging_' + opts.type : '')) + .append( + $('