Um app de tutorial confia no usuário: assume que o CSV está bem-formado, que o campo numérico tem número, que quem acessa pode acessar. Um app de produção assume o contrário: dados chegam sujos, usuários erram, e alguém mal-intencionado vai testar os limites. Este módulo constrói as três camadas que separam protótipo de produto — comunicar (notificações e modais), defender a entrada (validação) e proteger o app (autenticação e mitigação de ataques).

Dar feedback ao usuário com showNotification, modalDialog e barras de progresso; validar entradas de forma robusta e amigável com req, validate/need e o pacote shinyvalidate; e aplicar fundamentos de segurança em Shiny: autenticação, gestão de segredos, e mitigação de injeção de SQL e XSS.

Notificações: o feedback efêmero

O usuário clicou em "salvar" — funcionou? Silêncio é a pior resposta. showNotification dá um retorno leve e temporário.

observeEvent(input$salvar, {
  # ... faz o salvamento ...
  showNotification(
    "Registro salvo com sucesso.",
    type = "message",       # "default" | "message" | "warning" | "error"
    duration = 4            # segundos; NULL = fica até o usuário fechar
  )
})

Para operações com etapas, você pode atualizar uma notificação existente pelo seu id — útil para "processando… → concluído":

observeEvent(input$processar, {
  id <- showNotification("Processando...", duration = NULL, closeButton = FALSE)
  on.exit(removeNotification(id), add = TRUE)   # garante remoção ao final

  resultado <- tarefa_demorada()
  showNotification("Concluído!", id = id, type = "message", duration = 3)
})

Barras de progresso para tarefas longas

Se algo demora mais de um ou dois segundos, o usuário precisa ver que o app está trabalhando. withProgress/incProgress mostram uma barra.

observeEvent(input$rodar, {
  withProgress(message = "Treinando modelo", value = 0, {
    n <- 10
    for (i in seq_len(n)) {
      etapa(i)                                   # trabalho real
      incProgress(1/n, detail = paste("passo", i, "de", n))
    }
  })
})

Para o padrão moderno e não bloqueante (a UI continua responsiva enquanto processa), a combinação é promises + future — cada tarefa pesada roda num processo separado. Esse é o assunto de processos externos com callr; por ora, saiba que withProgress resolve a maioria dos casos.

Modais: quando exigir atenção ou confirmação

Uma notificação é ignorável; um modal bloqueia até o usuário decidir. Use para confirmações destrutivas ("tem certeza que quer excluir?") e formulários focados.

# Abrir um modal de confirmação
observeEvent(input$excluir, {
  showModal(modalDialog(
    title = "Confirmar exclusão",
    "Esta ação não pode ser desfeita. Deseja continuar?",
    footer = tagList(
      modalButton("Cancelar"),                       # fecha o modal
      actionButton("confirma_excluir", "Excluir", class = "btn-danger")
    ),
    easyClose = FALSE                                 # não fecha clicando fora
  ))
})

# Só age depois da confirmação explícita
observeEvent(input$confirma_excluir, {
  removeModal()
  excluir_registro()
  showNotification("Registro excluído.", type = "warning")
})

O padrão "modal de confirmação → ação só no botão de confirmação" é a defesa mais barata contra ações destrutivas acidentais. Note que input$excluir só abre o modal; a exclusão real depende de input$confirma_excluir.

Validação: a diferença entre req e validate

Retomando a arquitetura reativa com mais profundidade. Há dois tipos de "parar":

  • req() — pré-condição técnica, aborta em silêncio. Use quando a ausência é normal e o usuário não precisa de explicação (ex.: ainda não subiu arquivo).
  • validate(need()) — pré-condição de negócio, exibe mensagem amigável no lugar do output. Use para orientar o usuário sobre o que corrigir.
output$grafico <- renderPlot({
  req(input$arquivo)                                        # técnico: silêncio
  dados <- read.csv(input$arquivo$datapath)

  validate(
    need(ncol(dados) >= 2, "O arquivo precisa ter ao menos 2 colunas."),
    need(input$col_x %in% names(dados), "Selecione uma coluna X válida."),
    need(is.numeric(dados[[input$col_y]]), "A coluna Y precisa ser numérica.")
  )

  plot(dados[[input$col_x]], dados[[input$col_y]])
})

Validação de formulários com shinyvalidate

Para formulários com vários campos, validar dentro de cada render fica repetitivo. O pacote shinyvalidate centraliza regras por input e mostra a mensagem embaixo do campo, como frameworks web modernos.

library(shiny)
library(shinyvalidate)

server <- function(input, output, session) {

  iv <- InputValidator$new()                       # cria o validador

  iv$add_rule("email", sv_required())              # obrigatório
  iv$add_rule("email", sv_email())                 # formato de e-mail
  iv$add_rule("idade", sv_integer())               # inteiro
  iv$add_rule("idade", sv_between(18, 120))         # faixa
  iv$add_rule("senha", function(value) {           # regra customizada
    if (nchar(value) < 8) "A senha precisa ter ao menos 8 caracteres."
  })

  iv$enable()                                       # liga a validação visual

  observeEvent(input$enviar, {
    if (iv$is_valid()) {                            # só prossegue se tudo ok
      salvar_cadastro(input$email, input$idade)
      showNotification("Cadastro realizado!", type = "message")
    } else {
      iv$enable()                                   # revela as mensagens de erro
      showNotification("Corrija os campos destacados.", type = "error")
    }
  })
}

A vantagem sobre validate/need: as mensagens ficam junto de cada campo (não no lugar de um output), a checagem é declarativa (add_rule), e iv$is_valid() dá um "portão" único antes de submeter. Validadores podem ser compostos entre módulos — importante para programação em Shiny.

Segurança I — autenticação e autorização

Shiny, por si só, não tem login. Não deixe um app com dados sensíveis exposto sem uma camada de autenticação. As opções, do mais simples ao mais robusto:

  • Camada da plataforma (recomendado): Posit Connect, ShinyProxy ou um reverse proxy (Nginx + OAuth2) cuidam do login antes de o app carregar. É a abordagem mais segura, porque a autenticação não vive dentro do código do app.
  • Pacotes de auth no app: shinymanager, polished ou firebase adicionam uma tela de login ao próprio app. Prático para casos menores.
library(shinymanager)

# Credenciais (em produção: em banco/hash, NUNCA em texto no código)
credenciais <- data.frame(
  user = c("ana", "admin"),
  password = c("$7$C6..hash...", "$7$C6..hash..."),  # hashes, não texto puro
  admin = c(FALSE, TRUE),
  stringsAsFactors = FALSE
)

ui <- secure_app(fluidPage(
  # ... sua UI normal ...
))

server <- function(input, output, session) {
  res_auth <- secure_server(check_credentials(credenciais))
  # A partir daqui, o app só roda para usuários autenticados.
  # res_auth$user, res_auth$admin permitem autorização por papel.
}

Autenticação ("quem é você?") é diferente de autorização ("o que você pode fazer?"). Depois de logado, use o papel do usuário (res_auth$admin) para mostrar/esconder abas, permitir edição só a administradores etc. — combinando com shinyjs (programação em Shiny) para ocultar controles.

Segurança II — segredos: nunca no código

Chaves de API, senhas de banco e tokens jamais devem aparecer no código-fonte (nem no Git). Use variáveis de ambiente.

# ERRADO — segredo no código, vai para o Git e vaza
con <- dbConnect(RPostgres::Postgres(), password = "senha123")

# CERTO — lê de variável de ambiente (definida no servidor ou em .Renviron)
con <- dbConnect(
  RPostgres::Postgres(),
  host     = Sys.getenv("DB_HOST"),
  user     = Sys.getenv("DB_USER"),
  password = Sys.getenv("DB_PASSWORD")   # nunca literal no código
)

O arquivo .Renviron (guarda os segredos localmente) precisa estar no .gitignore. Em produção, as variáveis são injetadas pelo ambiente (Docker --env, ShinyProxy, secrets do orquestrador). Isso é detalhado em implantação com Docker.

Segurança III — injeção de SQL

Se o app monta consultas concatenando texto vindo do usuário, ele está aberto a SQL injection. A defesa é parametrizar — nunca colar o input direto na query.

# ERRADO — concatenação: um input malicioso reescreve a query
query <- paste0("SELECT * FROM clientes WHERE nome = '", input$nome, "'")
# input$nome = "'; DROP TABLE clientes; --"  → catástrofe

# CERTO — query parametrizada: o driver trata o valor como DADO, não código
res <- dbGetQuery(
  con,
  "SELECT * FROM clientes WHERE nome = ?",
  params = list(input$nome)
)

# Alternativa com glue_sql, também segura (escapa corretamente)
library(glue)
q <- glue_sql("SELECT * FROM clientes WHERE nome = {input$nome}", .con = con)

O placeholder ? (ou $1, conforme o driver) garante que o banco trate a entrada como valor, não como parte do comando SQL. Essa é uma regra absoluta: toda query com dado do usuário é parametrizada.

Segurança IV — XSS e conteúdo do usuário

Se você renderiza texto do usuário como HTML (via HTML(), renderUI, htmlOutput), um invasor pode injetar <script> — um ataque de XSS (Cross-Site Scripting). A regra: nunca passe entrada de usuário por HTML() sem sanitizar.

# PERIGOSO — renderiza HTML bruto do usuário
output$saida <- renderUI({
  HTML(input$comentario)         # se o comentário tiver <script>, executa!
})

# SEGURO — trata como texto puro (escapa < > & etc.)
output$saida <- renderText({
  input$comentario               # renderText/textOutput escapam por padrão
})

# Se PRECISAR de HTML do usuário, sanitize antes
library(sanitizeHTML)  # ou htmltools::htmlEscape para escapar
output$saida <- renderUI({
  HTML(sanitize_html(input$comentario))   # remove tags perigosas
})

Regra prática: prefira textOutput/renderText (que escapam automaticamente) para exibir conteúdo do usuário. Só use HTML()/renderUI com conteúdo que você controla, ou depois de sanitizar.

Camadas juntas: um fluxo defensivo completo

server <- function(input, output, session) {
  iv <- InputValidator$new()
  iv$add_rule("valor", sv_required()); iv$add_rule("valor", sv_gt(0))
  iv$enable()

  observeEvent(input$transferir, {
    # 1) valida a entrada
    if (!iv$is_valid()) {
      showNotification("Corrija os campos.", type = "error"); return()
    }
    # 2) confirma ação sensível
    showModal(modalDialog(
      "Confirmar transferência de R$ ", format(input$valor), "?",
      footer = tagList(modalButton("Não"),
                       actionButton("ok_transf", "Sim", class = "btn-danger"))
    ))
  })

  observeEvent(input$ok_transf, {
    removeModal()
    id <- showNotification("Processando...", duration = NULL)
    on.exit(removeNotification(id), add = TRUE)
    # 3) query parametrizada (sem injeção)
    dbExecute(con, "UPDATE contas SET saldo = saldo - ? WHERE id = ?",
              params = list(input$valor, isolate(usuario_id())))
    showNotification("Transferência concluída.", id = id, type = "message")
  })
}

Esse fluxo — validar → confirmar → executar com segurança → dar feedback — é o esqueleto de qualquer ação sensível num app de produção.

O feedback efêmero é o showNotification, que aceita um id para se atualizar de "processando" para "pronto"; para tarefas longas, withProgress e incProgress mostram barras. O modalDialog bloqueia a tela em confirmações destrutivas, seguindo uma regra que evita acidente: abrir com uma ação, executar só no botão de confirmação. Entre os dois jeitos de parar, o req() é pré-condição técnica e aborta em silêncio, enquanto validate(need()) é regra de negócio e mostra mensagem no lugar do output. O shinyvalidate centraliza as regras por campo com add_rule e os sv_*, exibe o erro sob cada input e oferece o portão iv$is_valid(). Sobre acesso: o Shiny não tem login nativo, e o melhor caminho é autenticar na plataforma — Connect, ShinyProxy ou um reverse proxy —, com o shinymanager servindo casos menores; e vale distinguir autenticação de autorização. Segredos vão em variáveis de ambiente lidas com Sys.getenv, nunca no código, com o .Renviron no .gitignore. Contra SQL injection, sempre parametrizar com ?, params ou glue_sql, jamais concatenar input. Contra XSS, preferir renderText e textOutput, que escapam, e reservar HTML() e renderUI para conteúdo próprio ou sanitizado. Toda ação sensível segue o mesmo esqueleto: validar, confirmar, executar com segurança e dar feedback.

Exercícios

  1. Quando usar req() e quando usar validate(need())? Dê um exemplo de cada.

    ✓ Resposta:

    req() para pré-condições técnicas cuja ausência é normal e não precisa de explicação — ex.: req(input$arquivo) antes de qualquer upload (aborta em silêncio, deixando o output vazio). validate(need(...)) para regras de negócio em que o usuário precisa saber o que corrigir — ex.: validate(need(is.numeric(dados[[col]]), "A coluna precisa ser numérica.")), que substitui o output por uma mensagem amigável. Resumo: silêncio técnico → req; orientação ao usuário → validate/need.

  2. Por que a exclusão de um registro deve depender de input$confirma_excluir e não de input$excluir diretamente?

    ✓ Resposta:

    Porque input$excluir é o clique que abre o modal de confirmação, não a decisão final. Ações destrutivas devem exigir um segundo passo explícito: o modal apresenta o aviso e só o botão confirma_excluir dentro dele efetiva a exclusão. Isso evita exclusões acidentais (clique errado) — o padrão "abrir com uma ação, executar só na confirmação" é a proteção mínima para operações irreversíveis.

  3. Este código é vulnerável. Aponte o problema e corrija: > ``r > q <- paste0("SELECT * FROM users WHERE email = '", input$email, "'") > dbGetQuery(con, q) > ``

    ✓ Resposta:

    É vulnerável a SQL injection: concatenar input$email na string permite que uma entrada como ' OR '1'='1 ou '; DROP TABLE users; -- altere ou destrua a consulta. A correção é parametrizar, deixando o driver tratar o valor como dado:

    dbGetQuery(con, "SELECT * FROM users WHERE email = ?",
               params = list(input$email))

    Alternativamente, glue_sql("... WHERE email = {input$email}", .con = con), que escapa corretamente. Nunca cole entrada de usuário direto na query.

  4. Um colega vai exibir comentários de usuários com renderUI({ HTML(input$comentario) }). Que risco isso cria e qual a alternativa segura?

    ✓ Resposta:

    Cria risco de XSS (Cross-Site Scripting): se o comentário contiver <script>...</script> (ou um atributo onerror etc.), o HTML() renderiza e o navegador executa esse código, permitindo roubo de sessão, redirecionamentos maliciosos etc. A alternativa segura é exibir como texto puro com renderText/textOutput, que escapam < > & automaticamente. Se HTML for realmente necessário, sanitizar antes (remover tags/atributos perigosos) — nunca renderizar HTML de usuário sem tratamento.

  5. Por que autenticar na plataforma (ShinyProxy, reverse proxy) é geralmente mais seguro que implementar login dentro do próprio app?

    ✓ Resposta:

    Porque a autenticação acontece antes de o código do app carregar: usuários não autenticados nunca chegam ao Shiny, reduzindo a superfície de ataque. A lógica de login (sessões, hashes, tokens, OAuth) fica numa camada madura e auditada (proxy/IdP), não misturada ao código de negócio, onde é fácil errar. Além disso, centraliza a política de acesso para vários apps. Login no app (shinymanager) é aceitável para casos menores, mas coloca a responsabilidade de segurança dentro da aplicação.

Fontes e leituras recomendadas