%%%=================================================================== %%% @author aj heller %%% @author Eric Bailey %%% @copyright 2012, aj heller; 2016, Eric Bailey; 2016-2020, elli-lib team %%% @end %%% @doc A library application for reading and managing cookies in elli. %%% @end %%% Created : 3 Oct 2012 by aj heller %%%=================================================================== -module(elli_cookie). %% Basic Cookie Management -export([parse/1, get/2, get/3, new/2, new/3, delete/1, delete/2]). %% Cookie Options -export([expires/1, path/1, domain/1, secure/0, http_only/0, max_age/1]). -export_type([cookie/0, cookie_list/0, expiration/0, expires/0, max_age/0]). %% @type cookie(). A tuple of name and value where both are binary strings. -type cookie() :: {binary(), binary()}. %% @type cookie_list(). A list of {@link cookie(). cookies}. -type cookie_list() :: [cookie()]. %% @type cookie_option(). Either a supported atom or a tuple of supported atom %% and binary/string value. %% %% Supported options are currently: %%
%%
`{expires, Date :: binary() | string()}'
%%
%% `Date' should be the current date in RFC %% 1123/ %% 822 format. %%
%%
`{max_age, MaxAge :: non_neg_integer()}'
%%
`MaxAge' is a Unix timestamp.
%%
`{path, Path}'
%%
%% According to RFC 2965, %% "the value of the `Path' attribute specifies the subset of URLs on the %% origin server to which this cookie applies." %%
%%
`{domain, Domain}'
%%
%% According to RFC 2965, %% "the value of the `Domain' attribute specifies the domain for which the %% cookie is valid. If an explicitly specified value does not start with a %% dot, the user agent supplies a leading dot." %%
%%
`secure'
%%
%% According to RFC 2965, %% "the Secure attribute... directs the user agent to use only... secure %% means to contact the origin server whenever it sends back this cookie..." %%
%%
`http_only'
%%
Make an HTTP-only cookie.
%%
-type cookie_option() :: {expires, binary() | string()} | {max_age, non_neg_integer()} | {path, binary() | string()} | {domain, binary() | string()} | secure | http_only. %% @type expiration(). For convience, {@type expires()} can be expressed as a %% non-negative number of `seconds | minutes | hours | days | weeks' from now. %% {@link expires/1} will convert an {@type expiration()} to an {@type %% expires()}. -type expiration() :: {non_neg_integer(), seconds | minutes | hours | days | weeks} | non_neg_integer(). %% @type expires(). See description under {@type cookie_option()}. -type expires() :: {expires, binary() | string()}. %% @type max_age(). Per RFC %% 2965, "the value of the Max-Age attribute is delta-seconds, %% the lifetime of the cookie in seconds, a decimal non-negative integer." -type max_age() :: {max_age, non_neg_integer()}. %% @doc Return a proplist made from the submitted cookies. -spec parse(Req :: elli:req()) -> no_cookies | cookie_list(). parse(Req) -> tokenize(elli_request:get_header(<<"Cookie">>, Req)). %% @equiv get(Key, Cookies, undefined) -spec get(Key, Cookies) -> binary() | undefined when Key :: binary() | string(), Cookies :: no_cookies | cookie_list(). get(Key, Cookies) -> get(Key, Cookies, undefined). %% @doc Retrieve a specific cookie value from the set of parsed cookies. %% If there is not a value for `Key' in `Cookies', return `Default'. -spec get(Key, Cookies, Default) -> binary() | Default when Key :: binary() | string(), Cookies :: no_cookies | cookie_list(). get(_, no_cookies, Default) -> Default; get(Key, Cookies, Default) -> ok = valid_cookie_name(Key), proplists:get_value(to_bin(Key), Cookies, Default). %% @equiv new(Name, Value, []) -spec new(Name, Value) -> cookie() when Name :: binary() | string(), Value :: binary() | string(). new(Name, Value) -> new(Name, Value, []). %% @doc Create a new cookie in a format appropriate for a server response. -spec new(Name, Value, Options) -> cookie() when Name :: binary() | string(), Value :: binary() | string(), Options :: [cookie_option()]. new(Name, Value, Options) -> ok = valid_cookie_name(Name), ok = valid_cookie_value(Value), BName = to_bin(Name), BValue = to_bin(Value), Bin = <>, FinalBin}. %% @equiv delete(Name, []) -spec delete(Name :: binary() | string()) -> cookie(). delete(Name) -> delete(Name, []). %% @doc Create a header that will delete a specific cookie on the client. -spec delete(Name, Options) -> cookie() when Name :: binary() | string(), Options :: [cookie_option()]. delete(Name, Options) -> ok = valid_cookie_name(Name), new(Name, "", [expires({{1970,1,1},{0,0,0}}) | Options]). %%%=================================================================== %%% Cookie Option helpers %%%=================================================================== %% @doc Set a path for a cookie. -spec path(Path) -> {path, Path} when Path :: binary() | string(). path(P) -> {path, P}. %% @doc Set a domain for a cookie. -spec domain(Domain) -> {domain, Domain} when Domain :: binary() | string(). domain(P) -> {domain, P}. %% @doc Make a cookie secure (SSL). -spec secure() -> secure. secure() -> secure. %% @doc Make an HTTP-only cookie. -spec http_only() -> http_only. http_only() -> http_only. %% @doc Set cookie expiration. -spec expires(Expiration :: expiration() | calendar:datetime()) -> expires(). expires({S, seconds}) -> expires_plus(S); expires({M, minutes}) -> expires_plus(M*60); expires({H, hours}) -> expires_plus(H*60*60); expires({D, days}) -> expires_plus(D*24*60*60); expires({W, weeks}) -> expires_plus(W*7*24*60*60); expires(Date) -> {expires, httpd_util:rfc1123_date(Date)}. %% @doc Set cookie `Max-Age'. -spec max_age(Expiration :: expiration()) -> max_age(). max_age({S, seconds}) -> {max_age, (S)}; max_age({M, minutes}) -> {max_age, (M*60)}; max_age({H, hours}) -> {max_age, (H*60*60)}; max_age({D, days}) -> {max_age, (D*24*60*60)}; max_age({W, weeks}) -> {max_age, (W*7*24*60*60)}; max_age(Seconds) -> {max_age, Seconds}. %%%=================================================================== %%% Internal functions %%%=================================================================== -spec to_bin(binary() | string()) -> binary(). to_bin(B) when is_binary(B) -> B; to_bin(L) when is_list(L) -> list_to_binary(L); to_bin(X) -> throw({error, {not_a_string, X}}). -spec strip_bin(binary()) -> binary(). strip_bin(B) -> list_to_binary(string:strip(binary_to_list(B))). tokenize(<<>>) -> []; tokenize(CookieStr) when is_binary(CookieStr) -> Cookies = binary:split(CookieStr, <<";">>, [trim, global]), lists:map(fun do_tokenize/1, Cookies); tokenize(_) -> no_cookies. -spec do_tokenize(Cookie :: binary()) -> {Name :: binary(), Value :: binary()}. do_tokenize(NVP) -> case binary:split(NVP, <<"=">>, [trim]) of [N,V] -> {strip_bin(N), strip_bin(V)}; [N] -> {strip_bin(N), <<>>} end. set_cookie_attribute({expires, Exp}, Bin) -> BExp = to_bin(Exp), <>; set_cookie_attribute(http_only, Bin) -> <>; set_cookie_attribute(X, _) -> throw({error, {invalid_cookie_attribute, X}}). expires_plus(N) -> UT = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), UTE = UT + N, Date = calendar:gregorian_seconds_to_datetime(UTE), {expires, httpd_util:rfc1123_date(Date)}. %%%=================================================================== %%% Predicates %%%=================================================================== %% TODO: implement cookie spec checking: https://tools.ietf.org/html/rfc6265 -spec valid_cookie_name(Name) -> ok | {invalid_cookie_name, Name}. valid_cookie_name(B) when is_binary(B) -> Str = binary_to_list(B), do_valid_cookie_name(string:str(Str, "="), B); valid_cookie_name(N) when is_list(N) -> do_valid_cookie_name(string:str(N, "="), N); valid_cookie_name(X) -> {invalid_cookie_name, X}. -spec do_valid_cookie_name(Index, Name) -> ok | {invalid_cookie_name, Name} when Index :: non_neg_integer(). do_valid_cookie_name(0, _) -> ok; do_valid_cookie_name(_, N) -> {invalid_cookie_name, N}. %% TODO: implement cookie spec checking: https://tools.ietf.org/html/rfc6265 -spec valid_cookie_value(Value) -> ok | {invalid_cookie_value, Value}. valid_cookie_value(B) when is_binary(B); is_list(B) -> ok; valid_cookie_value(X) -> {invalid_cookie_value, X}.