diff --git a/components/app/R/global.R b/components/app/R/global.R index e410da07c..52e227859 100644 --- a/components/app/R/global.R +++ b/components/app/R/global.R @@ -169,6 +169,8 @@ opt.default <- list( ENABLE_HEARTBEAT = TRUE, ENABLE_INACTIVITY = TRUE, ENABLE_ANNOT = FALSE, + ENABLE_STARTUP_MODAL = TRUE, + INTEGRATION_TRANSLATION = FALSE, ENABLE_UPGRADE = FALSE, ENCRYPTED_EMAIL = FALSE, MAX_DATASETS = 25, @@ -284,3 +286,10 @@ library(shiny.i18n) DICTIONARY <- file.path(FILES, "translation.json") i18n <- shiny.i18n::Translator$new(translation_json_path = DICTIONARY) i18n$set_translation_language("RNA-seq") + +## Init integration translator +if (opt$INTEGRATION_TRANSLATION) { + DICTIONARY2 <- file.path(FILES, "translation_integration.json") + i18n_int <- shiny.i18n::Translator$new(translation_json_path = DICTIONARY2) + i18n_int$set_translation_language("integration") +} diff --git a/components/app/R/server.R b/components/app/R/server.R index 9dbe31b1d..d279d4397 100644 --- a/components/app/R/server.R +++ b/components/app/R/server.R @@ -117,6 +117,14 @@ app_server <- function(input, output, session) { allow_new_users = opt$ALLOW_NEW_USERS, redirect_login = TRUE ) + } else if (authentication == "email-encrypted") { + auth <- EmailEncryptedAuthenticationModule( + id = "auth", + show_modal = TRUE, + skip_modal = TRUE, + # TODO add argument for location of crypto key (probably on ETC) + domain = opt$DOMAIN + ) } else if (authentication == "shinyproxy") { username <- Sys.getenv("SHINYPROXY_USERNAME") auth <- NoAuthenticationModule( @@ -1224,7 +1232,7 @@ app_server <- function(input, output, session) { ## Startup Message dbg("[MAIN] showing startup modal") observeEvent(auth$logged, { - if (auth$logged) { + if (auth$logged && opt$ENABLE_STARTUP_MODAL) { shinyjs::delay(500, { ## read startup messages msg <- readLines(file.path(ETC, "MESSAGES")) diff --git a/components/board.upload/R/upload_module_preview_counts.R b/components/board.upload/R/upload_module_preview_counts.R index 7e363dad4..7a9b79118 100644 --- a/components/board.upload/R/upload_module_preview_counts.R +++ b/components/board.upload/R/upload_module_preview_counts.R @@ -128,6 +128,22 @@ upload_table_preview_counts_server <- function( action_buttons ) }, + if (is.matrix(uploaded$counts.csv) && nrow(uploaded$counts.csv) == 1 && ncol(uploaded$counts.csv) == 1) { + bslib::layout_columns( + col_widths = 12, + div( + style = "display: flex; justify-content: center; align-items: center; position: absolute; top: 21px; left: 21px; right: 21px; bottom: 21px; z-index: 9999; background: white;", + shiny::tags$div( + style = "width: 100%; display: flex; justify-content: center; align-items: center;", + shiny::tags$div( + class = "spinner-border text-primary", + style = "width: 5rem; height: 5rem;", + shiny::tags$span(class = "visually-hidden", "Loading...") + ) + ) + ) + ) + }, if (!is.null(uploaded$counts.csv)) { bslib::layout_columns( col_widths = 12, diff --git a/components/board.upload/R/upload_server.R b/components/board.upload/R/upload_server.R index 878d0bbcb..a2cfacc55 100644 --- a/components/board.upload/R/upload_server.R +++ b/components/board.upload/R/upload_server.R @@ -522,7 +522,7 @@ UploadBoard <- function(id, ) samples_ui <- wizardR::wizard_step( - step_title = "Step 2: Upload samples", + step_title = tspan("Step 2: Upload samples", js = FALSE), step_id = "step_samples", server = TRUE, upload_table_preview_samples_ui( @@ -684,11 +684,21 @@ UploadBoard <- function(id, # change upload_datatype to selected_datatype observeEvent(input$selected_datatype, { upload_datatype(input$selected_datatype) + query_files <- check_query_files() + if (!is.null(query_files$datatype) && new_upload() == 2) { + datatype <- query_files$datatype + upload_datatype(datatype) + } }) # change upload_organism to selected_organism observeEvent(input$selected_organism, { upload_organism(input$selected_organism) + query_files <- check_query_files() + if (!is.null(query_files$organism) && new_upload() == 2) { + organism <- query_files$organism + upload_organism(organism) + } }) observeEvent(input$start_upload, { @@ -945,7 +955,7 @@ UploadBoard <- function(id, wizardR::reset("upload_wizard") # skip upload trigger at first startup - if (new_upload() == 0) { + if (new_upload() == 1) { return(NULL) } @@ -976,6 +986,75 @@ UploadBoard <- function(id, ## compute_info(list( "name" = pgx$name,"description" = pgx$description)) compute_settings$name <- pgx$name compute_settings$description <- pgx$description + } else if (opt$AUTHENTICATION == "email-encrypted") { # Only data preloading on email-encrypted authentication + query_files <- check_query_files() + # Get encryption key + encryption_key <- tryCatch( + { + readLines(file.path(OPG, "etc/keys/encryption.txt"))[1] + }, + error = function(w) { + warning("[UploadServer] ERROR: missing encryption_key file!!!") + NULL + } + ) + # if (!file.exists(encryption_key)) { + # encryption_key <- NULL + # } + # Populate upload data with available + if (!is.null(query_files$counts) && new_upload() == 2) { + counts_url <- if (!is.null(encryption_key)) { + decrypt_util(query_files$counts, encryption_key) + } else { + warning("[UploadServer] No encryption key available, using raw URL") + query_files$counts + } + + # Set initial loading state + uploaded$counts.csv <- matrix(data = 1, nrow = 1, ncol = 1) + + future_promise({ + file <- read_query_files(counts_url) + df <- playbase::read_counts(file) + af <- playbase::read_annot(file) + list(counts = df, annot = af) + }) %...>% + (function(result) { + message("[UploadServer] Future completed successfully") + uploaded$counts.csv <- result$counts + uploaded$annot.csv <- result$annot + }) %...!% + (function(error) { + message("[UploadServer] Future failed with error: ", error$message) + warning("[UploadServer] Error processing counts file: ", error) + }) + } + if (!is.null(query_files$samples) && new_upload() == 2) { + # Decrypt the URL if encryption key is available + samples_url <- if (!is.null(encryption_key)) { + decrypt_util(query_files$samples, encryption_key) + } else { + warning("[UploadServer] No encryption key available, using raw URL") + query_files$samples + } + + file <- read_query_files(samples_url) + df <- playbase::read_samples(file) + uploaded$samples.csv <- df + } + if (!is.null(query_files$contrasts) && new_upload() == 2) { + # Decrypt the URL if encryption key is available + contrasts_url <- if (!is.null(encryption_key)) { + decrypt_util(query_files$contrasts, encryption_key) + } else { + warning("[UploadServer] No encryption key available, using raw URL") + query_files$contrasts + } + + file <- read_query_files(contrasts_url) + df <- playbase::read_contrasts(file) + uploaded$contrasts.csv <- df + } } } else { shinyalert::shinyalert( diff --git a/components/board.upload/R/upload_utils.R b/components/board.upload/R/upload_utils.R index 9b968a73a..f01356a32 100644 --- a/components/board.upload/R/upload_utils.R +++ b/components/board.upload/R/upload_utils.R @@ -308,3 +308,26 @@ write_check_output <- function( write(unlist(lines), file.path(raw_dir, "CHECKS_OUTPUT"), append = TRUE) } } + +# Check if there are counts/samples file links on the query parameters +check_query_files <- function() { + counts <- shiny::getQueryString()$counts + samples <- shiny::getQueryString()$samples + contrasts <- shiny::getQueryString()$contrasts + datatype <- shiny::getQueryString()$datatype + organism <- shiny::getQueryString()$organism + return(list( + counts = counts, + samples = samples, + contrasts = contrasts, + datatype = datatype, + organism = organism + )) +} + +# Read query files +read_query_files <- function(url) { + destination <- tempfile(fileext = ".csv") + download.file(url, destination) + return(destination) +} \ No newline at end of file diff --git a/components/modules/AuthenticationModule.R b/components/modules/AuthenticationModule.R index dcc34bfc3..fe85715a6 100644 --- a/components/modules/AuthenticationModule.R +++ b/components/modules/AuthenticationModule.R @@ -1251,7 +1251,146 @@ LoginCodeAuthenticationModule <- function(id, }) } +## ================================================================================ +## Utility functions for authentication +## ================================================================================ + +EmailEncryptedAuthenticationModule <- function( + id = "auth", + show_modal = TRUE, + skip_modal = FALSE, + domain = opt$DOMAIN) { + shiny::moduleServer(id, function(input, output, session) { + message("[AuthenticationModule] >>>> using EmailEncrypted authentication <<<<") + ns <- session$ns + + # Get encryption key + encryption_key <- tryCatch( + { + readLines(file.path(OPG, "etc/keys/encryption.txt"))[1] + }, + error = function(w) { + NULL + } + ) + if (is.null(encryption_key)) { + ## we continue without decryption, just to test, unsafe + warning("[EmailEncryptedAuthenticationModule] ERROR : missing encryption_key file!!!") + } + + USER <- shiny::reactiveValues( + method = "email-encrypted", + logged = FALSE, + username = NA, + email = NA, + level = "", + limit = "", + options = opt, ## global + user_dir = PGX.DIR ## global + ) + + if (show_modal) { + m <- splashLoginModal( + ns = ns, + with.username = FALSE, + with.email = FALSE, + with.password = FALSE, + title = "Sign in", + subtitle = "Ready to explore your data?", + button.text = "Sure I am!" + ) + shiny::showModal(m) + if (skip_modal) { + shinyjs::runjs("$('#auth-login_submit_btn').click();") + } + } + + resetUSER <- function() { + dbg("[EmailEncryptedAuthenticationModule] resetUSER called") + USER$logged <- FALSE + USER$username <- NA + USER$email <- NA + USER$password <- NA + USER$level <- "" + USER$limit <- "" + + PLOT_DOWNLOAD_LOGGER <<- reactiveValues(log = list(), str = "") + } + + output$showLogin <- shiny::renderUI({ + resetUSER() + }) + output$login_warning <- shiny::renderText("") + + ## -------------------------------------- + ## Step 1: Get email query field + ## -------------------------------------- + + query_email <- shiny::reactive({ + query_email <- shiny::getQueryString()$email + query_email + }) + + ## -------------------------------------- + ## Step 2: Decrypt and Login user + ## -------------------------------------- + shiny::observeEvent(input$login_submit_btn, { + if (is.null(query_email())) { + dbg("[EmailEncryptedAuthenticationModule] invalid email hash") + output$login_warning <- shiny::renderText("invalid email hash") + return(NULL) + } + + # Decrypt email if encryption key is available + decrypted_email <- if (!is.null(encryption_key)) { + decrypt_util(query_email(), encryption_key, remove_suffix = TRUE) + } else { + warning("[EmailEncryptedAuthenticationModule] No encryption key available, using raw email") + query_email() + } + + # Validate decrypted email + if (is.null(decrypted_email)) { + dbg("[EmailEncryptedAuthenticationModule] failed to decrypt email") + output$login_warning <- shiny::renderText("invalid email format") + return(NULL) + } + + USER$email <- decrypted_email + USER$username <- decrypted_email + USER$logged <- TRUE + + # TODO Use some sort of user_database to see if email allowed?? + # TODO check domain (?) + + # Set up user directory + USER$user_dir <- file.path(PGX.DIR, USER$email) + create_user_dir_if_needed(USER$user_dir, PGX.DIR) + if (!opt$ENABLE_USERDIR) { + USER$user_dir <- file.path(PGX.DIR) + } + + dbg("[EmailEncryptedAuthenticationModule] using user OPTIONS") + USER$options <- read_user_options(USER$user_dir) + session$sendCustomMessage("set-user", list(user = USER$email)) + + # Handle query files and navigation + query_files <- check_query_files() |> unlist() + if (!is.null(query_files)) { + bigdash.selectTab(session, "upload-tab") + shinyjs::runjs("$('#upload-start_upload').click();") + } else { + bigdash.selectTab(session, "load-tab") + } + }) + + ## export as 'public' functions + USER$resetUSER <- resetUSER + + return(USER) + }) +} ## ================================================================================ ## ================================= END OF FILE ================================== ## ================================================================================ diff --git a/components/modules/AuthenticationModule_functions.R b/components/modules/AuthenticationModule_functions.R index e308d80db..bd7033f3e 100644 --- a/components/modules/AuthenticationModule_functions.R +++ b/components/modules/AuthenticationModule_functions.R @@ -18,7 +18,7 @@ check_user_options_db <- function(email, user_database = NULL) { if (is.null(user_database)) { return(FALSE) } - connection <- connect_db(user_database) + connection <- DBI::dbConnect(RSQLite::SQLite(), dbname = user_database) user_opt <- query_by_email(email, connection) if (is.null(user_opt)) { return(FALSE) # user NOT in db @@ -47,16 +47,14 @@ read_user_options <- function(user_dir) { new_opt[[opt_name]] <- user_opt[[opt_name]] } } - # add user dir to opt file (IK: this is not an option!) - ## new_opt$user_dir <- user_dir new_opt } read_user_options_db <- function(email, user_database = NULL) { - connection <- connect_db(user_database) + connection <- DBI::dbConnect(RSQLite::SQLite(), dbname = user_database) user_opt <- query_by_email(email, connection) new_opt <- opt ## opt from global - disconnect_db(connection) + DBI::dbDisconnect(connection) if (!is.null(user_opt)) { ## restrict user options only to these options. ALLOWED_USER_OPTS <- c( @@ -109,71 +107,71 @@ upgrade.dialog <- function(ns, current.plan) { if (current.plan == "starter") btn_starter <- "Current Plan" if (current.plan == "premium") btn_premium <- "Current Plan" - modalDialog( - title = h3("Find the right OmicsPlayground plan for you"), + shiny::modalDialog( + title = shiny::h3("Find the right OmicsPlayground plan for you"), size = "m", - div( + shiny::div( class = "row", style = "padding-left:4rem;padding-right:4rem;text-align:center;", - div( + shiny::div( class = "col-md-4", style = "background:#F2FAFF;", - HTML("

Basic

"), - p("Try for free"), - h3("Free!"), - tags$ul( + shiny::HTML("

Basic

"), + shiny::p("Try for free"), + shiny::h3("Free!"), + shiny::tags$ul( class = "list-unstyled", - tags$li("Host up to 3 datasets"), - tags$li("45 minutes time limit"), - tags$li("Up to 25 samples / dataset"), - tags$li("Up to 5 comparisons") + shiny::tags$li("Host up to 3 datasets"), + shiny::tags$li("45 minutes time limit"), + shiny::tags$li("Up to 25 samples / dataset"), + shiny::tags$li("Up to 5 comparisons") ), shiny::actionButton(ns("get_basic"), btn_basic), - br() + shiny::br() ), - div( + shiny::div( class = "col-md-4", style = "background:#E8F8FF;", - h4(HTML("Starter")), - p("Great to start"), - h3("Soon!"), - tags$ul( + shiny::h4(shiny::HTML("Starter")), + shiny::p("Great to start"), + shiny::h3("Soon!"), + shiny::tags$ul( class = "list-unstyled", - tags$li("Host up to 10 datasets"), - tags$li("3 hours time limit"), - tags$li("Up to 100 samples / dataset"), - tags$li("Up to 10 comparisons") + shiny::tags$li("Host up to 10 datasets"), + shiny::tags$li("3 hours time limit"), + shiny::tags$li("Up to 100 samples / dataset"), + shiny::tags$li("Up to 10 comparisons") ), shiny::actionButton(ns("get_starter"), btn_starter), - br() + shiny::br() ), - div( + shiny::div( class = "col-md-4", style = "background:#E2F4FF;", - HTML("

Premium

"), - p("For power users or small groups"), - h3("Soon!"), - tags$ul( + shiny::HTML("

Premium

"), + shiny::p("For power users or small groups"), + shiny::h3("Soon!"), + shiny::tags$ul( class = "list-unstyled", - tags$li("Host up to 100 datasets"), - tags$li("8 hours time limit"), - tags$li("Up to 2000 samples / dataset"), - tags$li("Up to 100 comparisons") + shiny::tags$li("Host up to 100 datasets"), + shiny::tags$li("8 hours time limit"), + shiny::tags$li("Up to 2000 samples / dataset"), + shiny::tags$li("Up to 100 comparisons") ), shiny::actionButton(ns("get_premium"), btn_premium), - br() + shiny::br() ) ), ## content div - div( + shiny::div( style = "margin-top:3rem;text-align:center;", - HTML("Looking for OmicsPlayground for Enterprise? Contact sales for info and pricing.") + shiny::HTML("Looking for OmicsPlayground for Enterprise? Contact sales for info and pricing.") ), - footer = tagList( - fillRow( + footer = shiny::tagList( + shiny::fillRow( flex = c(NA, 0.03, NA, 1, NA, NA), - tags$label( + shiny::tags$label( class = "radio-inline", - tags$input( + shiny::tags$input( id = "yearlyCheck", type = "radio", name = "yearly", @@ -182,10 +180,10 @@ upgrade.dialog <- function(ns, current.plan) { ), "Billed yearly" ), - br(), - tags$label( + shiny::br(), + shiny::tags$label( class = "radio-inline", - tags$input( + shiny::tags$input( id = "monthlyCheck", type = "radio", name = "monthly", @@ -193,9 +191,9 @@ upgrade.dialog <- function(ns, current.plan) { ), "Billed monthly" ), - br(), + shiny::br(), shiny::actionButton(ns("manage"), "Manage Subscription"), - modalButton("Dismiss") + shiny::modalButton("Dismiss") ) ) ) ## modalDialog @@ -243,7 +241,7 @@ checkAuthorizedUser <- function(email, credentials_file = NULL) { if (!file.exists(credentials_file)) { return(TRUE) } - CREDENTIALS <- read.csv(credentials_file, colClasses = "character") + CREDENTIALS <- utils::read.csv(credentials_file, colClasses = "character") valid_user <- tolower(email) %in% tolower(CREDENTIALS$email) if (!valid_user) { return(FALSE) @@ -261,15 +259,13 @@ checkExpiredUser <- function(email, user_database) { if (!file.exists(user_database)) { return(TRUE) } - connection <- connect_db(user_database) - # connection <- DBI::dbConnect(RSQLite::SQLite(), dbname = user_database) + connection <- DBI::dbConnect(RSQLite::SQLite(), dbname = user_database) query_result <- DBI::dbGetQuery(connection, paste0(" SELECT expiry FROM users WHERE email = '", email, "' ")) - # DBI::dbDisconnect(connection) - disconnect_db(connection) + DBI::dbDisconnect(connection) if (nrow(query_result) == 0) { return(TRUE) } else { @@ -348,6 +344,62 @@ chueckHubspotData <- function(user_email) { } } +## ----------------------------------------------------------------- +## Encryption/Decryption functions +## ----------------------------------------------------------------- + +#' Decrypt text using AES-256-CBC +#' @param encrypted_text The encrypted text to decrypt +#' @param key The encryption key as a string +#' @return The decrypted text +decrypt_util <- function(encrypted_text, key, remove_suffix = FALSE) { + if (is.null(encrypted_text) || is.null(key)) return(NULL) + + # Convert key to raw bytes + key_raw <- charToRaw(key) + + # URL decode the encrypted text + encrypted_text <- utils::URLdecode(encrypted_text) + + # Split the IV and encrypted data + parts <- strsplit(encrypted_text, ":")[[1]] + if (length(parts) != 2) return(NULL) + + # Decode base64 components + iv <- openssl::base64_decode(parts[1]) + encrypted_data <- openssl::base64_decode(parts[2]) + + # Decrypt using AES-256-CBC + tryCatch({ + decrypted <- openssl::aes_cbc_decrypt( + encrypted_data, + key = key_raw, + iv = iv + ) + decrypted_text <- rawToChar(decrypted) + + if (remove_suffix) { + BLACKLIST_FILE <- file.path(ETC, "blacklisted_suffixes.txt") + suffix <- substr(decrypted_text, nchar(decrypted_text) - 7, nchar(decrypted_text)) + if (!file.exists(BLACKLIST_FILE)) { + dir.create(dirname(BLACKLIST_FILE), recursive = TRUE, showWarnings = FALSE) + file.create(BLACKLIST_FILE) + } + if (system(paste("grep -q", shQuote(suffix), shQuote(BLACKLIST_FILE)), + ignore.stdout = TRUE, ignore.stderr = TRUE) == 0) { + warning("[decrypt_util] Blacklisted suffix detected") + return(NULL) + } + decrypted_text <- substr(decrypted_text, 1, nchar(decrypted_text) - 8) + write(suffix, file = BLACKLIST_FILE, append = TRUE) + } + decrypted_text + }, error = function(e) { + warning("[decrypt_util] Decryption failed: ", e$message) + NULL + }) +} + ## ================================================================================ ## ================================= END OF FILE ================================== ## ================================================================================ diff --git a/components/ui/ui-utils.R b/components/ui/ui-utils.R index 790b2e678..c88c06b9e 100644 --- a/components/ui/ui-utils.R +++ b/components/ui/ui-utils.R @@ -457,7 +457,7 @@ tspan <- function(text, js = TRUE) { if (nchar(text) == 0) { return("") } - if (!grepl("gene|counts|transcriptomics|rna-seq|logcpm", + if (!grepl("gene|counts|transcriptomics|rna-seq|logcpm|step", text, ignore.case = TRUE )) { @@ -481,6 +481,18 @@ tspan <- function(text, js = TRUE) { text <- paste0("", text, "") text <- shiny::HTML(text) } + + # Integration translator (does not accept JS TRUE) + if (opt$INTEGRATION_TRANSLATION) { + keys <- c( + "Upload" + ) + i18n.tr <- function(key) as.character(i18n_int$get_translations()[key, "integration"]) + for (k in keys) { + tt <- i18n.tr(k) + if (grepl(k, text)) text <- gsub(k, tt, text, ignore.case = FALSE) + } + } text } diff --git a/lib/translation_integration.json b/lib/translation_integration.json new file mode 100644 index 000000000..2c1bb6532 --- /dev/null +++ b/lib/translation_integration.json @@ -0,0 +1,13 @@ +{ + "cultural_date_format": "%d-%m-%Y", + "languages": [ + "default", + "integration" + ], + "translation": [ + { + "default": "Upload", + "integration": "Review" + } + ] +}