diff --git a/DESCRIPTION b/DESCRIPTION index f9859f80..d10c0a45 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: shiny.telemetry Title: 'Shiny' App Usage Telemetry -Version: 0.2.0.9003 +Version: 0.2.0.9004 Authors@R: c( person("André", "Veríssimo", , "opensource+andre@appsilon.com", role = c("aut", "cre")), person("Kamil", "Żyła", , "kamil@appsilon.com", role = "aut"), @@ -33,7 +33,8 @@ Imports: rlang, RSQLite, shiny, - tidyr + tidyr, + htmltools Suggests: box, config, diff --git a/NEWS.md b/NEWS.md index 9481b502..3761bbc0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,7 @@ - Updated `get_user` method to retrieve user in `shinyproxy` environment (#124). - Added flexibility to select between [`RPostgreSQL`, `RPostgres`] drivers (#147). +- Added tracking for returning anonymous users (#142). ### Miscellaneous diff --git a/R/telemetry.R b/R/telemetry.R index f03b9014..6fd71423 100644 --- a/R/telemetry.R +++ b/R/telemetry.R @@ -109,6 +109,10 @@ Telemetry <- R6::R6Class( # nolint object_name_linter #' Shiny session. #' @param username Character with username. If set, it will overwrite username #' from session object. + #' @param track_anonymous_user flag that indicates to track anonymous user. + #' A cookie is used to track same user without login over multiple sessions, + #' This is only activated if none of the automatic methods produce a username + #' and when a username is not explicitly defined.`TRUE` by default #' #' @return Nothing. This method is called for side effects. @@ -120,7 +124,8 @@ Telemetry <- R6::R6Class( # nolint object_name_linter browser_version = TRUE, navigation_input_id = NULL, session = shiny::getDefaultReactiveDomain(), - username = NULL + username = NULL, + track_anonymous_user = TRUE ) { checkmate::assert_flag(track_inputs) @@ -128,10 +133,11 @@ Telemetry <- R6::R6Class( # nolint object_name_linter checkmate::assert_flag(login) checkmate::assert_flag(logout) checkmate::assert_flag(browser_version) + checkmate::assert_flag(track_anonymous_user) checkmate::assert_character(navigation_input_id, null.ok = TRUE) - username <- private$get_user(session, username) + username <- private$get_user(session, username, track_anonymous_user) checkmate::assert( .combine = "or", @@ -715,18 +721,56 @@ Telemetry <- R6::R6Class( # nolint object_name_linter ) } }, - + extract_cookie = function(cookie_string, cookie_name = "shiny_user_cookie") { + checkmate::assert_string(cookie_string, null.ok = TRUE) + checkmate::assert_string(cookie_name, null.ok = FALSE) + if (is.null(cookie_string)) return(NULL) + cookies <- strsplit(cookie_string, ";")[[1]] + cookies <- trimws(cookies) + for (cookie in cookies) { + parts <- strsplit(cookie, "=")[[1]] + if (length(parts) == 2 && parts[1] == cookie_name) { + # Check if the value looks like a SHA256 hash + cookie_value <- parts[2] + if (grepl("^[a-f0-9]{64}$", cookie_value)) { + return(cookie_value) + } + } + } + NULL + }, get_user = function( session = shiny::getDefaultReactiveDomain(), - force_username = NULL + force_username = NULL, + track_anonymous_user = TRUE ) { if (!is.null(force_username)) return(force_username) if (isFALSE(is.null(session)) && isFALSE(is.null(session$user))) { - return(session$user) # POSIT Connect + session$user # POSIT Connect } else if (nzchar(Sys.getenv("SHINYPROXY_USERNAME"))) { - return(Sys.getenv("SHINYPROXY_USERNAME")) + Sys.getenv("SHINYPROXY_USERNAME") + } else if (track_anonymous_user) { + cookie_value <- private$extract_cookie(cookie_string = session$request$HTTP_COOKIE) + # cookie_value will be NULL if either not found or not generated using SHA256 algorithm. + if (is.null(cookie_value)) { + cookie_value <- digest::digest( + c( + session$token, + session$request$HTTP_USER_AGENT, + session$request$REMOTE_ADDR, + Sys.time() + ), + algo = "sha256" + ) + session$sendCustomMessage("setUserCookie", list( + cookieName = "shiny_user_cookie", + cookieValue = cookie_value, + expiryInDays = 365 + )) + } + cookie_value } else { - return(NULL) + NULL } } ) diff --git a/R/ui_elements.R b/R/ui_elements.R index 48a1ad2e..f557c285 100644 --- a/R/ui_elements.R +++ b/R/ui_elements.R @@ -8,16 +8,13 @@ use_telemetry <- function(id = "") { checkmate::assert_string(id, null.ok = TRUE) shiny_namespace <- "" - if (id != "" && !is.null(id)) { - shiny_namespace <- glue::glue("{id}-") + if (!is.null(id) && !identical(trimws(id), "")) { + shiny_namespace <- shiny::NS(trimws(id), "") } - - - shiny::tagList( + shiny::singleton(shiny::tagList( shiny::tags$script( type = "text/javascript", - shiny::HTML(paste0( - " + shiny::HTML(paste0(" $(document).on('shiny:sessioninitialized', function(event) { var br_ver = (function(){ var ua= navigator.userAgent, tem, M; @@ -34,12 +31,22 @@ use_telemetry <- function(id = "") { if((tem= ua.match(/version\\/(\\d+)/i))!= null) M.splice(1, 1, tem[1]); return M.join(' '); })(); - ", - glue::glue("Shiny.setInputValue(\"{shiny_namespace}browser_version\", br_ver);"), - " - }); - " - )) + ", glue::glue("Shiny.setInputValue(\"{shiny_namespace}browser_version\", br_ver);"), "});")) + ), + htmltools::htmlDependency( + name = "js-cookie", + version = "3.0.5", + src = "js/js-cookie-v3.0.5", + package = "shiny.telemetry", + script = "js.cookie.min.js" + ), + htmltools::htmlDependency( + name = "manage-cookie", + version = "1.0.0", + src = "js", + package = "shiny.telemetry", + script = "manage-cookies.js" ) ) + ) } diff --git a/inst/js/js-cookie-v3.0.5/LICENSE.txt b/inst/js/js-cookie-v3.0.5/LICENSE.txt new file mode 100644 index 00000000..b96dcb04 --- /dev/null +++ b/inst/js/js-cookie-v3.0.5/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/inst/js/js-cookie-v3.0.5/js.cookie.min.js b/inst/js/js-cookie-v3.0.5/js.cookie.min.js new file mode 100644 index 00000000..962d48d0 --- /dev/null +++ b/inst/js/js-cookie-v3.0.5/js.cookie.min.js @@ -0,0 +1,2 @@ +/*! js-cookie v3.0.5 | MIT */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self,function(){var n=e.Cookies,o=e.Cookies=t();o.noConflict=function(){return e.Cookies=n,o}}())}(this,(function(){"use strict";function e(e){for(var t=1;t}} } @@ -165,6 +166,11 @@ Shiny session.} \item{\code{username}}{Character with username. If set, it will overwrite username from session object.} + +\item{\code{track_anonymous_user}}{flag that indicates to track anonymous user. +A cookie is used to track same user without login over multiple sessions, +This is only activated if none of the automatic methods produce a username +and when a username is not explicitly defined.\code{TRUE} by default} } \if{html}{\out{}} }