knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE)
library(shiny) library(brochure)
Every page of a brochure app is a new Shiny session. Nothing computed on one page survives a navigation to another. That is deliberate, and it means state shared between pages has to be written down somewhere: a cookie, and usually a backend keyed by what the cookie holds.
A cookie is a response header, so it is set from a res_handler:
page( href = "/login", ui = tagList(h1("You are logged in")), res_handlers = list( ~ set_cookie(.x, "SESSION", "a-random-id", path = "/") ) )
set_cookie() defaults to http_only = TRUE and same_site = "Lax", so the
cookie is not readable from javascript, and is kept off cross-site subrequests
— an image, a form post, a fetch from another site. "Lax" is not total
isolation: the cookie still travels when someone follows a link to your app
from elsewhere, which is what makes it usable for a session at all. Use
same_site = "Strict" if even that is too much.
Pass secure = TRUE in production, where the app is served over https.
Names and values are checked against the character set cookies allow. A value
carrying a ;, a newline or a quote is rejected rather than silently truncating
the header or, worse, appending one of its own.
From a page server:
server <- function(input, output, session) { cookies <- parse_cookie_string(get_cookies()) print(cookies["SESSION"]) }
Index with single brackets: a request carrying no Cookie header at all gives
nothing to index into, and [[ raises "subscript out of bounds" where [
returns NA.
get_cookies() gives the raw Cookie header; parse_cookie_string() turns it
into a named vector.
page( href = "/logout", ui = tagList(h1("You are logged out")), res_handlers = list( ~ remove_cookie(.x, "SESSION", path = "/") ) )
Pass remove_cookie() the same path and domain you gave set_cookie(). A
browser only replaces a cookie when the name, the path and the domain all
match; with a different path it files the deletion as a separate cookie and
leaves the original alone. This bites hardest behind a reverse proxy, where the
default path is the mount rather than /, so the deletion appears to work
locally and does nothing once deployed.
The cookie holds an identifier; the data lives server side, keyed by it.
store <- cachem::cache_disk() # 32 hex characters from the system's cryptographic source. `sample()` would # read from R's default generator, whose state is recoverable from its output: # fine for a simulation, not for something that has to be unguessable. new_session_id <- function() { paste( sprintf("%02x", as.integer(openssl::rand_bytes(16))), collapse = "" ) } login <- function() { page( href = "/login", ui = tagList(h1("Welcome back")), res_handlers = list( # Runs once per request, so each visitor gets an identifier of their own. # Building it in `login()` instead would run it once, when the app is # assembled, and hand every visitor the same session. function(res, req) { id <- new_session_id() store$set(id, list(user = "colin", at = Sys.time())) set_cookie(res, "SESSION", id, path = "/") } ) ) } home <- function() { page( href = "/", ui = tagList(h1("Home"), verbatimTextOutput("who")), server = function(input, output, session) { output$who <- renderPrint({ # Single brackets: with no Cookie header at all there is nothing to # index into, and `[[` would raise "subscript out of bounds" before # the fallback below is reached. id <- parse_cookie_string(get_cookies())["SESSION"] if (is.na(id)) "anonymous" else store$get(id) }) } ) }
The store is what decides what a session may do; the cookie only names it. So the identifier has to be unguessable, and it has to be minted per visitor — two mistakes that are easy to make and silent when made.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.