%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2022 Marc Worrell
%% @doc Request context for Zotonic request evaluation.
%% Copyright 2009-2022 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(z_context).
-author("Marc Worrell <marc@worrell.nl>").
-export([
new/1,
new/2,
new/3,
new_tests/0,
site/1,
hostname/1,
hostname_port/1,
hostname_ssl_port/1,
site_protocol/1,
is_ssl_site/1,
db_pool/1,
db_driver/1,
is_request/1,
is_session/1,
prune_for_spawn/1,
prune_for_async/1,
prune_for_database/1,
prune_for_scomp/1,
is_site_url/2,
site_url/2,
abs_url/2,
pickle/1,
depickle/1,
depickle_site/1,
init_cowdata/3,
get_reqdata/1,
set_reqdata/2,
get_envdata/1,
set_envdata/2,
get_controller_module/1,
set_controller_module/2,
get_render_state/1,
set_render_state/2,
output/2,
ensure_qs/1,
set_q/3,
set_q/2,
add_q/3,
add_q/2,
get_q/2,
get_q/3,
get_qargs/1,
get_q_all/1,
get_q_all/2,
get_q_all_noz/1,
get_q_validated/2,
get_q_map/1,
get_q_map_noz/1,
set_q_all/2,
q_upload_keepalive/2,
without_zotonic_args/1,
is_zotonic_arg/1,
logger_md/1,
logger_md/2,
ensure_logger_md/1,
client_id/1,
client_topic/1,
set_client_context/2,
session_id/1,
set_session_id/2,
set/3,
set/2,
get/2,
get/3,
get_all/1,
language/1,
fallback_language/1,
set_language/2,
tz/1,
tz_config/1,
set_tz/2,
set_csp_nonce/1,
csp_nonce/1,
set_resp_header/3,
set_resp_headers/2,
get_resp_header/2,
get_req_header/2,
get_req_path/1,
set_req_metrics/2,
set_nocache_headers/1,
set_noindex_header/1,
set_noindex_header/2,
set_resource_headers/2,
set_security_headers/1,
set_cors_headers/2,
set_cookie/3,
set_cookie/4,
get_cookie/2,
get_cookies/2,
set_state_cookie/2,
get_state_cookie/1,
state_cookie_secret/1,
reset_state_cookie/1,
cookie_domain/1
]).
-include_lib("zotonic.hrl").
-define(STATE_SECRET_COOKIE, <<"z.state">>).
%% @doc Return a new empty context, no request is initialized.
-spec new( z:context() | atom() | cowboy_req:req() ) -> z:context().
new(#context{} = C) ->
#context{
site = C#context.site,
language = C#context.language,
tz = C#context.tz,
depcache = C#context.depcache,
dispatcher = C#context.dispatcher,
template_server = C#context.template_server,
scomp_server = C#context.scomp_server,
dropbox_server = C#context.dropbox_server,
pivot_server = C#context.pivot_server,
module_indexer = C#context.module_indexer,
db = C#context.db,
translation_table = C#context.translation_table
};
new(undefined) ->
% TODO: check if the fallback site is running
case z_sites_dispatcher:get_fallback_site() of
{ok, Site} -> new(Site);
undefined -> throw({error, no_site_enabled})
end;
new(Site) when is_atom(Site) ->
set_default_language_tz(
set_server_names(#context{ site = Site })).
%% @doc Create a new context record for a site with a certain language
-spec new( Site :: atom(), Language :: atom() ) -> z:context().
new(Site, Lang) when is_atom(Site), is_atom(Lang) ->
Context = set_server_names(#context{ site = Site }),
Context#context{
language = [ Lang ],
tz = tz_config(Context)
}.
-spec new( Site :: atom(), Language :: atom(), Timezone :: binary() ) -> z:context().
new(Site, Lang, Timezone) when is_atom(Site), is_atom(Lang), is_binary(Timezone) ->
Context = set_server_names(#context{ site = Site }),
Context#context{
language = [ Lang ],
tz = Timezone
}.
-spec set_default_language_tz(z:context()) -> z:context().
set_default_language_tz(Context) ->
try
F = fun() ->
{z_language:default_language(Context), tz_config(Context)}
end,
{DefaultLang, TzConfig} = z_depcache:memo(F, default_language_tz, ?DAY, [config], Context),
Context#context{
language = [DefaultLang],
tz = TzConfig
}
catch
error:badarg ->
% The depache is gone, happens during race conditions on site shutdown.
% Silently return a default.
Context#context{
language = [ en ],
tz = z_config:get(timezone)
}
end.
% @doc Create a new context used when testing parts of zotonic
new_tests() ->
Context = z_trans_server:set_context_table(
#context{
site = test,
language = [en],
tz = <<"UTC">>
}),
case ets:info(Context#context.translation_table) of
undefined ->
ets:new(Context#context.translation_table,
[named_table, set, protected, {read_concurrency, true}]);
_TabInfo ->
ok
end,
Context.
%% @doc Set all server names for the given site.
-spec set_server_names( z:context() ) -> z:context().
set_server_names(#context{ site = Site } = Context) ->
SiteAsList = [ $$ | atom_to_list(Site) ],
Context1 = Context#context{
depcache = list_to_atom("z_depcache"++SiteAsList),
dispatcher = list_to_atom("z_dispatcher"++SiteAsList),
template_server = list_to_atom("z_template"++SiteAsList),
scomp_server = list_to_atom("z_scomp"++SiteAsList),
dropbox_server = list_to_atom("z_dropbox"++SiteAsList),
pivot_server = list_to_atom("z_pivot_rsc"++SiteAsList),
module_indexer = list_to_atom("z_module_indexer"++SiteAsList),
translation_table = z_trans_server:table(Site)
},
Context1#context{
% session_manager=list_to_atom("z_session_manager"++SiteAsList),
db = { z_db_pool:db_pool_name(Site), z_db_pool:db_driver(Context1) }
}.
%% @doc Maps the site in the request to a site in the sites folder.
-spec site(z:context() | cowboy_req:req()) -> atom().
site(#context{site=Site}) ->
Site;
site(Req) when is_map(Req) ->
case maps:get(cowmachine_site, Req, undefined) of
undefined ->
{ok, Site} = z_sites_dispatcher:get_fallback_site(),
Site;
Site when is_atom(Site) ->
Site
end.
%% @doc Return the preferred hostname from the site configuration
-spec hostname( z:context() ) -> binary().
hostname(Context) ->
case z_dispatcher:hostname(Context) of
undefined -> <<"localhost">>;
<<>> -> <<"localhost">>;
<<"none">> ->
case is_request(Context) of
true -> cowmachine_req:host(Context);
false -> <<"localhost">>
end;
Hostname -> Hostname
end.
%% @doc Return the hostname (and port) for http from the site configuration
-spec hostname_port( z:context() ) -> binary() | undefined.
hostname_port(Context) ->
case z_dispatcher:hostname_port(Context) of
Empty when Empty =:= undefined; Empty =:= <<>> ->
case z_config:get(port) of
none -> undefined;
80 -> <<"localhost">>;
Port -> <<"localhost:", (integer_to_binary(Port))/binary>>
end;
Hostname ->
Hostname
end.
%% @doc Return the hostname (and port) for https from the site configuration
-spec hostname_ssl_port( z:context() ) -> binary() | undefined.
hostname_ssl_port(Context) ->
case z_dispatcher:hostname_ssl_port(Context) of
Empty when Empty =:= undefined; Empty =:= <<>> ->
case z_config:get(ssl_port) of
none -> undefined;
443 -> <<"localhost">>;
Port -> <<"localhost:", (integer_to_binary(Port))/binary>>
end;
Hostname ->
Hostname
end.
%% @doc Check if the current context is a request context
-spec is_request( z:context() ) -> boolean().
is_request(#context{ cowreq = undefined }) -> false;
is_request(#context{}) -> true.
%% @doc Check if the current context has an active MQTT session.
%% This is never true for the first request.
-spec is_session( z:context() ) -> boolean().
is_session(#context{ client_topic = undefined }) ->
false;
is_session(#context{ client_topic = ClientTopic }) ->
is_list(ClientTopic).
%% @doc Minimal prune, for ensuring that the context can safely used in two processes
-spec prune_for_spawn( z:context() ) -> z:context().
prune_for_spawn(#context{} = Context) ->
Context#context{
dbc = undefined
}.
%% @doc Make the context safe to use in a async message. This removes render_state and the db transaction.
-spec prune_for_async( z:context() ) -> z:context().
prune_for_async(#context{} = Context) ->
Context#context{
dbc = undefined,
render_state = undefined
}.
%% @doc Cleanup a context so that it can be used exclusively for database connections
-spec prune_for_database( z:context() ) -> z:context().
prune_for_database(Context) ->
#context{
site = Context#context.site,
db = Context#context.db,
dbc = Context#context.dbc,
depcache = Context#context.depcache,
% session_manager=Context#context.session_manager,
dispatcher = Context#context.dispatcher,
template_server = Context#context.template_server,
scomp_server = Context#context.scomp_server,
dropbox_server = Context#context.dropbox_server,
pivot_server = Context#context.pivot_server,
module_indexer = Context#context.module_indexer
}.
%% @doc Cleanup a context for cacheable scomp handling. Resets most the render_state to prevent duplicating
%% between different (cached) renderings.
-spec prune_for_scomp( z:context() ) -> z:context().
prune_for_scomp(Context) ->
Context#context{
dbc = undefined,
cowreq = prune_reqdata(Context#context.cowreq),
cowenv = prune_envdata(Context#context.cowenv),
render_state = undefined
}.
prune_reqdata(undefined) ->
undefined;
prune_reqdata(Req) ->
%% @todo: prune this better, also used by the websocket connection.
Req#{
bindings => #{},
headers => #{},
path => <<>>,
qs => <<>>,
pid => undefined,
streamid => undefined
}.
prune_envdata(undefined) ->
undefined;
prune_envdata(Env) ->
%% @todo: prune this better, also used by the websocket connection.
Env#{
cowmachine_cookies => [],
cowmachine_resp_body => <<>>
}.
%% @doc Check if the URL is an URL of the local site
-spec is_site_url( undefined | string() | binary(), z:context() ) -> boolean().
is_site_url(undefined, _Context) -> false;
is_site_url(<<"//", _/binary>> = Url, Context) -> is_site_url_1(Url, Context);
is_site_url(<<"/", _/binary>>, _Context) -> true;
is_site_url(<<"#", _/binary>>, _Context) -> true;
is_site_url([ $/, $/ | _ ] = Url, Context) -> is_site_url_1(Url, Context);
is_site_url([$/ | _], _Context) -> true;
is_site_url([$# | _], _Context) -> true;
is_site_url(Url, Context) -> is_site_url_1(Url, Context).
is_site_url_1(Url, Context) ->
case z_sites_dispatcher:get_site_for_url(Url) of
{ok, Site} ->
Site =:= site(Context);
undefined ->
false
end.
%% @doc Ensure that an URL is an URL to the current site. If not then return
%% the URL of the homepage. If the URL is not a fragment then the returned URL
%% is always sanitized and absolute.
-spec site_url(Url, Context) -> SiteUrl when
Url :: undefined | string() | binary(),
Context :: z:context(),
SiteUrl :: binary().
site_url(undefined, Context) ->
abs_url(<<"/">>, Context);
site_url("#" ++ _ = Frag, _Context) ->
z_sanitize:uri(z_convert:to_binary(Frag));
site_url(<<"#", _/binary>> = Frag, _Context) ->
z_sanitize:uri(Frag);
site_url(Url, Context) ->
Url1 = z_sanitize:uri(Url),
case is_site_url(Url1, Context) of
true ->
abs_url(Url1, Context);
false ->
abs_url(<<"/">>, Context)
end.
%% @doc Make the url an absolute url by prepending the hostname.
-spec abs_url(undefined | iodata(), z:context()) -> binary().
abs_url(Url, Context) when is_list(Url) ->
abs_url(iolist_to_binary(Url), Context);
abs_url(<<"//", _/binary>> = Url, Context) ->
case m_req:get(scheme, Context) of
undefined -> <<"https:", Url/binary>>;
https -> <<"https:", Url/binary>>;
http -> <<"http:", Url/binary>>
end;
abs_url(<<$/, _/binary>> = Url, Context) ->
case z_notifier:first(#url_abs{ url = Url }, Context) of
undefined ->
Hostname = hostname(Context),
case z_config:get(ssl_port) of
443 -> <<"https://", Hostname/binary, Url/binary>>;
Port -> <<"https://", Hostname/binary, $:, (integer_to_binary(Port))/binary, Url/binary>>
end;
AbsUrl ->
AbsUrl
end;
abs_url(undefined, Context) ->
abs_url(<<>>, Context);
abs_url(Url, Context) ->
case has_url_protocol(Url) of
true -> Url;
false -> abs_url(<<$/, Url/binary>>, Context)
end.
has_url_protocol(<<"http:", _/binary>>) -> true;
has_url_protocol(<<"https:", _/binary>>) -> true;
has_url_protocol(<<"ws:", _/binary>>) -> true;
has_url_protocol(<<"wss:", _/binary>>) -> true;
has_url_protocol(<<"ftp:", _/binary>>) -> true;
has_url_protocol(<<"email:", _/binary>>) -> true;
has_url_protocol(<<"file:", _/binary>>) -> true;
has_url_protocol(<<H, T/binary>>) when H >= $a andalso H =< $z ->
has_url_protocol_1(T);
has_url_protocol(_) ->
false.
has_url_protocol_1(<<H, T/binary>>) when H >= $a andalso H =< $z ->
has_url_protocol_1(T);
has_url_protocol_1(<<$:, _/binary>>) -> true;
has_url_protocol_1(_) -> false.
%% @doc Fetch the pid of the database worker pool for this site
-spec db_pool(z:context()) -> atom().
db_pool(#context{ db = {Pool, _Driver} }) ->
Pool.
%% @doc Fetch the database driver module for this site
-spec db_driver(z:context()) -> atom().
db_driver(#context{ db = {_Pool, Driver} }) ->
Driver.
%% @doc Fetch the protocol for absolute urls referring to the site (always https).
-spec site_protocol(z:context()) -> binary().
site_protocol(_Context) ->
<<"https">>.
%% @doc Check if the preferred protocol of the site is https (always true)
-spec is_ssl_site(z:context()) -> boolean().
is_ssl_site(_Context) ->
true.
%% @doc Pickle a context for storing in the database
-spec pickle( z:context() ) -> tuple().
pickle(Context) ->
{pickled_context,
Context#context.site,
Context#context.user_id,
Context#context.language,
Context#context.tz,
undefined}.
%% @doc Depickle a context for restoring from a database
-spec depickle( tuple() ) -> z:context().
depickle({pickled_context, Site, UserId, Language, _VisitorId}) ->
Context = set_server_names(#context{ site = Site, language = Language }),
ContextTz = Context#context{ tz = tz_config(Context) },
case UserId of
undefined -> ContextTz;
_ -> z_acl:logon(UserId, ContextTz)
end;
depickle({pickled_context, Site, UserId, Language, Tz, _VisitorId}) ->
Context = set_server_names(#context{ site = Site, language = Language, tz = Tz }),
case UserId of
undefined -> Context;
_ -> z_acl:logon(UserId, Context)
end.
%% @doc Depickle a context, return the site name.
-spec depickle_site( tuple() ) -> z:context().
depickle_site({pickled_context, Site, _UserId, _Language, _VisitorId}) ->
Site;
depickle_site({pickled_context, Site, _UserId, _Language, _Tz, _VisitorId}) ->
Site.
-spec output( MixedHtml::term(), z:context() ) -> {iolist(), z:context()}.
output(MixedHtml, Context) ->
z_output_html:output(MixedHtml, Context).
%% @doc Ensure that we have parsed the query string, fetch body if necessary.
%% If this is a POST then the session/page-session might be continued after this call.
ensure_qs(#context{ props = Props } = Context) ->
case maps:find('q', Props) of
{ok, _Qs} ->
Context;
error ->
Query = cowmachine_req:req_qs(Context),
PathInfo = cowmachine_req:path_info(Context),
PathArgs = [ {z_convert:to_binary(T), V} || {T,V} <- maps:to_list( PathInfo ) ],
QPropsUrl = Props#{ q => PathArgs++Query },
ContextQs = Context#context{ props = QPropsUrl },
% Auth user via cookie - set language
ContextReq = z_notifier:foldl(#request_context{ phase = init }, ContextQs, ContextQs),
ContextReq2 = z_notifier:foldl(#request_context{ phase = refresh }, ContextReq, ContextReq),
% Parse the POST body (if any)
{Body, ContextParsed} = parse_post_body(ContextReq2),
QPropsAll = (ContextParsed#context.props)#{ q => PathArgs++Body++Query },
ContextParsed#context{ props = QPropsAll }
end.
%% @doc Return the cowmachine request data of the context
-spec get_reqdata(z:context()) -> cowboy_req:req() | undefined.
get_reqdata(Context) ->
Context#context.cowreq.
%% @doc Set the cowmachine request data of the context
-spec set_reqdata(cowboy_req:req() | undefined, z:context()) -> z:context().
set_reqdata(Req, Context) when is_map(Req); Req =:= undefined ->
Context#context{ cowreq = Req }.
%% @doc Return the cowmachine request data of the context
-spec get_envdata(z:context()) -> cowboy_middleware:env() | undefined.
get_envdata(Context) ->
Context#context.cowenv.
%% @doc Set the cowmachine request data of the context
-spec set_envdata(cowboy_middleware:env() | undefined, z:context()) -> z:context().
set_envdata(Env, Context) when is_map(Env); Env =:= undefined ->
Context#context{ cowenv = Env }.
%% @doc Set the cowmachine request data of the context
-spec init_cowdata(cowboy_req:req(), cowboy_middleware:env(), z:context()) -> z:context().
init_cowdata(Req, Env, Context) when is_map(Req); Req =:= undefined ->
Context#context{
cowreq = Req,
cowenv = Env
}.
%% @doc Get the resource module handling the request.
-spec get_controller_module(z:context()) -> atom() | undefined.
get_controller_module(Context) ->
Context#context.controller_module.
-spec set_controller_module(Module::atom(), z:context()) -> z:context().
set_controller_module(Module, Context) ->
Context#context{ controller_module = Module }.
-spec get_render_state( z:context() ) -> z_render:render_state() | undefined.
get_render_state(#context{ render_state = RS }) ->
RS.
-spec set_render_state( z_render:render_state() | undefined, z:context() ) -> z:context().
set_render_state(RS, Context) ->
Context#context{ render_state = RS }.
%% @doc Set the value of a request parameter argument
%% Always filter the #upload{} arguments to prevent upload of non-temp files.
-spec set_q(binary()|string()|atom(), z:qvalue(), z:context()) -> z:context().
set_q(Key, #upload{ tmpfile = TmpFile } = Upload, Context) when TmpFile =/= undefined ->
set_q(Key, Upload#upload{ tmpfile = undefined }, Context);
set_q(Key, Value, Context) when is_binary(Key) ->
Qs = get_q_all(Context),
Qs1 = lists:keydelete(Key, 1, Qs),
z_context:set('q', [{Key,Value}|Qs1], Context);
set_q(Key, Value, Context) ->
set_q(z_convert:to_binary(Key), Value, Context).
%% @doc Set the value of multiple request parameter arguments
-spec set_q( list( {binary()|string()|atom(), z:qvalue()} ) | map(), z:context() ) -> z:context().
set_q(KVs, Context) when is_map(KVs) ->
maps:fold(
fun(K, V, Ctx) ->
set_q(K, V, Ctx)
end,
Context,
KVs);
set_q(KVs, Context) when is_list(KVs) ->
lists:foldl(
fun({K, V}, Ctx) ->
set_q(K, V, Ctx)
end,
Context,
KVs).
%% @doc Add the value of a request parameter argument
%% Always filter the #upload{} arguments to prevent upload of non-temp files.
-spec add_q(binary()|string()|atom(), z:qvalue(), z:context()) -> z:context().
add_q(Key, #upload{ tmpfile = TmpFile } = Upload, Context) when TmpFile =/= undefined ->
add_q(Key, Upload#upload{ tmpfile = undefined }, Context);
add_q(Key, Value, Context) when is_binary(Key) ->
Qs = get_q_all(Context),
z_context:set('q', [{Key,Value}|Qs], Context);
add_q(Key, Value, Context) ->
set_q(z_convert:to_binary(Key), Value, Context).
%% @doc Add the value of multiple request parameter arguments
-spec add_q( list(), z:context() ) -> z:context().
add_q(KVs, Context) ->
lists:foldl(
fun({K, V}, Ctx) ->
add_q(K, V, Ctx)
end,
Context,
KVs).
%% @doc Get a request parameter, either from the query string or the post body. Post body has precedence over the query string.
%% Note that this can also be populated from a JSON MQTT call, and as such contain arbitrary data.
-spec get_q(string()|atom()|binary()|list(), z:context()) -> undefined | z:qvalue().
get_q([Key|_] = Keys, Context) when is_list(Key); is_atom(Key); is_binary(Key) ->
lists:foldl(fun(K, Acc) ->
case get_q(K, Context) of
undefined -> Acc;
Value -> [{K, Value}|Acc]
end
end,
[],
Keys);
get_q(Key, Context) when is_list(Key) ->
case get_q(list_to_binary(Key), Context) of
Value when is_binary(Value) -> binary_to_list(Value);
Value -> Value
end;
get_q(Key, #context{ props = Props }) ->
case maps:find(q, Props) of
{ok, Qs} -> proplists:get_value(z_convert:to_binary(Key), Qs);
error -> undefined
end.
%% @doc Get a request parameter, either from the query string or the post body. Post body has precedence over the query string.
-spec get_q(binary()|string()|atom(), z:context(), term()) -> z:qvalue().
get_q(Key, Context, Default) when is_list(Key) ->
case get_q(list_to_binary(Key), Context, Default) of
Value when is_binary(Value) -> binary_to_list(Value);
Value -> Value
end;
get_q(Key, #context{ props = Props }, Default) ->
case maps:find(q, Props) of
{ok, Qs} -> proplists:get_value(z_convert:to_binary(Key), Qs, Default);
error -> Default
end.
%% @doc Fetch all arguments starting with a 'q'. This is used for queries.
-spec get_qargs( z:context() ) -> list( {binary(), z:qvalue()} ).
get_qargs(Context) ->
Qs = get_q_all(Context),
lists:foldr(fun
({<<"q", _/binary>>, _Value} = A, Acc) ->
[ A | Acc ];
(_, Acc) ->
Acc
end,
[],
Qs).
%% @doc Get all parameters.
-spec get_q_all(z:context()) -> list({binary(), z:qvalue()}).
get_q_all(#context{ props = Props }) ->
case maps:find(q, Props) of
{ok, Qs} -> Qs;
error -> []
end.
%% @doc Replace all parameters.
-spec set_q_all(list({binary(), z:qvalue()}), z:context()) -> z:context().
set_q_all(QArgs, #context{ props = Props } = Context) when is_list(QArgs) ->
Context#context{ props = Props#{ q => QArgs }}.
%% @doc Get the all the parameters with the same name, returns the empty list when non found.
-spec get_q_all(string()|atom()|binary(), z:context()) -> list( z:qvalue() ).
get_q_all(Key, Context) when is_list(Key) ->
Values = get_q_all(z_convert:to_binary(Key), Context),
[
case is_binary(V) of
true -> binary_to_list(V);
false -> V
end
|| V <- Values
];
get_q_all(Key, #context{ props = Props }) ->
case maps:find(q, Props) of
{ok, Qs} -> proplists:get_all_values(z_convert:to_binary(Key), Qs);
error -> []
end.
%% @doc Get all query/post args, filter the zotonic internal args.
-spec get_q_all_noz(z:context()) -> list({binary(), z:qvalue()}).
get_q_all_noz(Context) ->
lists:filter(fun({X,_}) -> not is_zotonic_arg(X) end, get_q_all(Context)).
%% @doc Get all query/post args, transformed into a map.
-spec get_q_map(z:context()) -> map().
get_q_map(Context) ->
Qs = get_q_all(Context),
{ok, Props} = z_props:from_qs(Qs),
maps:remove(<<"*">>, Props).
%% @doc Get all query/post args, transformed into a map.
%% Removes Zotonic vars and the dispatcher '*' variable.
-spec get_q_map_noz(z:context()) -> map().
get_q_map_noz(Context) ->
Qs = get_q_all_noz(Context),
{ok, Props} = z_props:from_qs(Qs),
maps:remove(<<"*">>, Props).
%% @doc Filter all Zotonic and dispatcher vars from a map.
-spec without_zotonic_args(map()) -> map().
without_zotonic_args(Map) ->
maps:fold(
fun(K, V, Acc) ->
case is_zotonic_arg(K) of
true -> Acc;
false -> Acc#{ K => V }
end
end,
#{},
Map).
% Known Zotonic rguments:
%
% - postback
% - triggervalue
% - zotonic_host
% - zotonic_site
% - zotonic_dispatch
% - zotonic_dispatch_path
% - zotonic_dispatch_path_rewrite
% - zotonic_ticket
% - zotonic_http_*
% - zotonic_topic_*
% - z_submitter
% - z_postback
% - z_trigger_id
% - z_target_id
% - z_delegate
% - z_message
% - z_transport
% - z_sid
% - z_pageid
% - z_v
% - z_msg
% - z_comet
% - z_postback_data
-spec is_zotonic_arg(binary()) -> boolean().
is_zotonic_arg(<<"postback">>) -> true;
is_zotonic_arg(<<"triggervalue">>) -> true;
is_zotonic_arg(<<"zotonic_", _/binary>>) -> true;
is_zotonic_arg(<<"z_", _/binary>>) -> true;
is_zotonic_arg(_) -> false.
%% @doc Fetch a query parameter and perform the validation connected to the parameter. An exception {not_validated, Key}
%% is thrown when there was no validator, when the validator is invalid or when the validation failed.
-spec get_q_validated(string()|atom()|binary(), z:context()) -> z:qvalue() | undefined.
get_q_validated([Key|_] = Keys, Context) when is_list(Key); is_atom(Key) ->
lists:foldl(fun (K, Acc) ->
case get_q_validated(K, Context) of
undefined -> Acc;
Value -> [{K, Value}|Acc]
end
end,
[],
Keys);
get_q_validated(Key, Context) when is_list(Key) ->
case get_q_validated(list_to_binary(Key), Context) of
V when is_binary(V) -> binary_to_list(V);
V -> V
end;
get_q_validated(Key, #context{ props = #{ q_validated := Qs } }) ->
case proplists:lookup(z_convert:to_binary(Key), Qs) of
{_Key, Value} -> Value;
none -> throw({not_validated, Key})
end;
get_q_validated(Key, _Context) ->
throw({not_validated, Key}).
%% @doc Keep the tempfiles alive by attaching the current process to its monitors
-spec q_upload_keepalive( boolean(), z:context() ) -> ok.
q_upload_keepalive(true, Context) ->
Qs = get_q_all(Context),
lists:map(
fun
(#upload{ tmpmonitor = Pid }) when is_pid(Pid) ->
z_tempfile:monitored_attach(Pid);
(_) ->
ok
end,
Qs),
ok;
q_upload_keepalive(false, Context) ->
Qs = get_q_all(Context),
lists:map(
fun
(#upload{ tmpmonitor = Pid }) when is_pid(Pid) ->
z_tempfile:monitored_detach(Pid);
(_) ->
ok
end,
Qs),
ok.
%% ------------------------------------------------------------------------------------
%% Set logger metadata for the current process
%% ------------------------------------------------------------------------------------
%% @doc Set the logger metadata for the current site or context.
-spec logger_md( z:context() | atom() ) -> ok.
logger_md(Site) when is_atom(Site) ->
logger_md(z_context:new(Site));
logger_md(Context) ->
logger_md(#{}, Context).
%% @doc Set the logger metadata, add the current site or context
-spec logger_md( map() | list(), z:context() ) -> ok.
logger_md(MetaData, #context{} = Context) when is_list(MetaData) ->
logger_md(maps:from_list(MetaData), Context);
logger_md(MetaData, #context{} = Context) when is_map(MetaData) ->
SessionId = case session_id(Context) of
{ok, Sid} -> Sid;
{error, _} -> undefined
end,
logger:set_process_metadata(MetaData#{
site => site(Context),
environment => m_site:environment(Context),
user_id => Context#context.user_id,
language => language(Context),
timezone => tz(Context),
controller => Context#context.controller_module,
dispatch => get(zotonic_dispatch, Context),
method => m_req:get(method, Context),
user_agent => m_req:get(user_agent, Context),
path => m_req:get(raw_path, Context),
remote_ip => m_req:get(peer, Context),
is_ssl => m_req:get(is_ssl, Context),
session_id => SessionId,
correlation_id => m_req:get(req_id, Context),
node => node()
}).
%% @doc Ensure that the logger metadata for this site and process is set.
-spec ensure_logger_md( z:context() | atom() ) -> ok.
ensure_logger_md(Context) ->
case logger:get_process_metadata() of
#{ site := _ } -> ok;
_ -> z_context:logger_md(Context)
end.
%% ------------------------------------------------------------------------------------
%% Set/get/modify state properties
%% ------------------------------------------------------------------------------------
%% @doc Return the current client id (if any)
-spec client_id( z:context() ) -> {ok, binary()} | {error, no_client}.
client_id(#context{ client_id = ClientId }) when is_binary(ClientId) ->
{ok, ClientId};
client_id(#context{}) ->
{error, no_client}.
%% @doc Return the current client bridge topic (if any)
-spec client_topic( z:context() ) -> {ok, mqtt_sessions:topic()} | {error, no_client}.
client_topic(#context{ client_topic = ClientTopic, client_id = ClientId }) when is_binary(ClientId), is_binary(ClientTopic) ->
{ok, mqtt_packet_map_topic:normalize_topic(ClientTopic)};
client_topic(#context{ client_topic = ClientTopic, client_id = ClientId }) when is_binary(ClientId), is_list(ClientTopic) ->
{ok, ClientTopic};
client_topic(#context{}) ->
{error, no_client}.
%% @doc Merge a context with client information into a request context.
%% This is used to merge a client context obtained from a MQTT ticket into
%% the contex of an out of band MQTT post.
%%
%% Access control, timezone, language and client information is copied over
%% from the client context to the request context.
-spec set_client_context( ClientContext::z:context(), ReqContext::z:context() ) -> z:context().
set_client_context(ClientContext, RequestContext) ->
Ctx = RequestContext#context{
client_id = ClientContext#context.client_id,
client_topic = ClientContext#context.client_topic,
routing_id = ClientContext#context.routing_id,
user_id = ClientContext#context.user_id,
acl = ClientContext#context.acl,
acl_is_read_only = ClientContext#context.acl_is_read_only,
tz = ClientContext#context.tz,
language = ClientContext#context.language
},
case maps:find(auth_options, ClientContext#context.props) of
{ok, AuthOptions} ->
z_context:set(auth_options, AuthOptions, Ctx);
error ->
Ctx
end.
%% @doc Return the unique random session id for the current client auth.
%% This session_id is re-assigned when the authentication of a client
%% changes.
-spec session_id( z:context() ) -> {ok, binary()} | {error, no_session}.
session_id(Context) ->
case get(session_id, Context) of
Sid when is_binary(Sid), Sid =/= <<>> ->
{ok, Sid};
_ ->
{error, no_session}
end.
%% @doc Set the cotonic session id. Mostly used when on a request with
%% a cotonic session id in the cookie.
-spec set_session_id( binary(), z:context() ) -> z:context().
set_session_id(Sid, Context) ->
set(session_id, Sid, Context).
%% @doc Set the value of the context variable Key to Value
-spec set( atom(), term(), z:context() ) -> z:context().
set(Key, Value, #context{ props = Props } = Context) ->
Context#context{ props = Props#{ Key => Value } }.
%% @doc Set the value of the context variables to all {Key, Value} properties.
-spec set( proplists:proplist(), z:context() ) -> z:context().
set(PropList, Context) when is_list(PropList) ->
NewProps = lists:foldl(
fun
({Key,Value}, Props) -> Props#{ Key => Value };
(Key, Props) -> Props#{ Key => true }
end,
Context#context.props,
PropList),
Context#context{ props = NewProps }.
%% @doc Fetch the value of the context variable Key, return undefined when Key is not found.
-spec get( atom(), z:context() ) -> term() | undefined.
get(Key, Context) ->
get(Key, Context, undefined).
%% @doc Fetch the value of the context variable Key, return Default when Key is not found.
-spec get( atom(), z:context(), term() ) -> term().
get(Key, Context, Default) ->
get_1(Key, Context, Default).
get_1(Key, #context{ props = Props } = Context, Default) ->
case maps:find(Key, Props) of
{ok, Value} -> Value;
error -> get_maybe_path_info(Key, Context, Default)
end.
get_maybe_path_info(z_language, Context, _Default) ->
z_context:language(Context);
get_maybe_path_info(zotonic_site, Context, _Default) ->
z_context:site(Context);
get_maybe_path_info(zotonic_dispatch, Context, Default) ->
get_path_info(zotonic_dispatch, Context, Default);
get_maybe_path_info(zotonic_dispatch_path, Context, Default) ->
get_path_info(zotonic_dispatch_path, Context, Default);
get_maybe_path_info(zotonic_dispatch_path_rewrite, Context, Default) ->
get_path_info(zotonic_dispatch_path_rewrite, Context, Default);
get_maybe_path_info(_, _Context, Default) ->
Default.
get_path_info(Key, Context, Default) ->
case is_request(Context) of
true ->
case maps:find(Key, cowmachine_req:path_info(Context)) of
{ok, Value} -> Value;
error -> Default
end;
false ->
Default
end.
%% @doc Return a proplist with all context variables.
-spec get_all( z:context() ) -> list().
get_all(Context) ->
maps:to_list(Context#context.props).
%% @doc Return the selected language of the Context
-spec language(z:context()) -> atom().
language(Context) ->
% A check on atom must exist because the language setting may be stored in mnesia and
% passed to the context when the site starts
case Context#context.language of
[Language|_] -> Language;
Language -> Language
end.
%% @doc Return the first fallback language of the Context
-spec fallback_language(z:context()) -> atom().
fallback_language(Context) ->
% Take the second item of the list, if it exists
case Context#context.language of
[_|[Fallback|_]] -> Fallback;
_ -> undefined
end.
%% @doc Set the language of the context, either an atom (language) or a list (language and fallback languages)
-spec set_language(atom()|binary()|string()|list(), z:context()) -> z:context().
set_language('x-default', Context) ->
Lang = z_language:default_language(Context),
Context#context{language=[Lang,'x-default']};
set_language(Lang, Context) when is_atom(Lang) ->
Context#context{language=[Lang]};
set_language(Langs, Context) when is_list(Langs) ->
Langs1 = lists:filter(fun z_language:is_valid/1, Langs),
Context#context{language=Langs1};
set_language(Lang, Context) ->
case z_language:is_valid(Lang) of
true -> set_language(z_convert:to_atom(Lang), Context);
false -> Context
end.
%% @doc Return the selected timezone of the Context; defaults to the site's timezone
-spec tz(z:context()) -> binary().
tz(#context{ tz = TZ }) when TZ =/= undefined; TZ =/= <<>> ->
TZ;
tz(Context) ->
tz_config(Context).
%% @doc Return the site's configured timezone.
-spec tz_config(z:context()) -> binary().
tz_config(Context) ->
case m_config:get_value(mod_l10n, timezone, Context) of
None when None =:= undefined; None =:= <<>> ->
z_config:get(timezone);
TZ ->
TZ
end.
%% @doc Set the timezone of the context.
-spec set_tz(string()|binary()|boolean(), z:context()) -> z:context().
set_tz(Tz, Context) when is_list(Tz) ->
set_tz(unicode:characters_to_binary(Tz, utf8), Context);
set_tz(Tz, Context) when is_binary(Tz), Tz =/= <<>> ->
case m_l10n:is_timezone(Tz) of
true ->
Context#context{ tz = Tz };
false ->
?LOG_INFO(#{
text => <<"Ignoring unknown timezone">>,
in => zotonic_core,
tz => Tz
}),
Context
end;
set_tz(true, Context) ->
Context#context{ tz = <<"UTC">> };
set_tz(1, Context) ->
Context#context{ tz = <<"UTC">> };
set_tz(0, Context) ->
Context;
set_tz(Tz, Context) ->
?LOG_ERROR(#{
text => <<"Ignoring unknown timezone">>,
in => zotonic_core,
tz => Tz
}),
Context.
%% @doc Set the Content-Security-Policy nonce for the request.
-spec set_csp_nonce( z:context() ) -> z:context().
set_csp_nonce(Context) ->
case get(csp_nonce, Context) of
undefined ->
Nonce = z_ids:id(),
set(csp_nonce, Nonce, Context);
Nonce when is_binary(Nonce) ->
Context
end.
%% @doc Return the Content-Security-Policy nonce for the request.
-spec csp_nonce( z:context() ) -> binary().
csp_nonce(Context) ->
case get(csp_nonce, Context) of
undefined ->
?LOG_WARNING(#{
text => <<"csp_nonce requested but not set">>,
in => zotonic_core
}),
<<>>;
Nonce when is_binary(Nonce) ->
Nonce
end.
%% @doc Set a response header for the request in the context.
-spec set_resp_header(binary(), binary(), z:context()) -> z:context().
set_resp_header(Header, Value, #context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:set_resp_header(Header, Value, Context).
%% @doc Set multiple response headers for the request in the context.
-spec set_resp_headers([ {binary(), binary()} ], z:context()) -> z:context().
set_resp_headers(Headers, #context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:set_resp_headers(Headers, Context).
%% @doc Get a response header
-spec get_resp_header(binary(), z:context()) -> binary() | undefined.
get_resp_header(Header, #context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:get_resp_header(Header, Context).
%% @doc Get a request header. The header MUST be in lower case.
-spec get_req_header(binary(), z:context()) -> binary() | undefined.
get_req_header(Header, #context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:get_req_header(Header, Context).
%% @doc Return the request path
-spec get_req_path(z:context()) -> binary().
get_req_path(#context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:raw_path(Context).
%% @doc Add metrics data to the Cowboy request, will be added to the metrics notifications.
-spec set_req_metrics( map(), z:context() ) -> ok.
set_req_metrics(Metrics, #context{ cowreq = Req }) when is_map(Req), is_map(Metrics) ->
cowboy_req:cast({set_options, #{ metrics_user_data => Metrics }}, Req),
ok;
set_req_metrics(_Metrics, _Context) ->
ok.
%% @doc Fetch the cookie domain, defaults to 'undefined' which will equal the domain
%% to the domain of the current request.
-spec cookie_domain(z:context()) -> binary() | undefined.
cookie_domain(Context) ->
case m_site:get(cookie_domain, Context) of
Empty when Empty =:= undefined; Empty =:= []; Empty =:= <<>> ->
undefined;
Domain ->
z_convert:to_binary(Domain)
end.
%% ------------------------------------------------------------------------------------
%% Local helper functions
%% ------------------------------------------------------------------------------------
%% @doc Return the keys in the body of the request, only if the request is application/x-www-form-urlencoded
-spec parse_post_body(z:context()) -> {list({binary(),binary()}), z:context()}.
parse_post_body(Context) ->
case cowmachine_req:get_req_header(<<"content-type">>, Context) of
<<"application/x-www-form-urlencoded", _/binary>> ->
case cowmachine_req:req_body(Context) of
{undefined, Context1} ->
{[], Context1};
{Body, Context1} ->
{cowmachine_util:parse_qs(Body), Context1}
end;
<<"multipart/form-data", _/binary>> ->
{Form, ContextRcv} = z_multipart_parse:recv_parse(Context),
FileArgs = [
{Name, #upload{filename=Filename, tmpfile=TmpFile, tmpmonitor=TmpPid}}
|| {Name, Filename, TmpFile, TmpPid} <- Form#multipart_form.files
],
{Form#multipart_form.args ++ FileArgs, ContextRcv};
_Other ->
{[], Context}
end.
%% @doc Some user agents have too aggressive client side caching.
%% These headers prevent the caching of content on the user agent iff
%% the content generated has a session. You can prevent addition of
%% these headers by not calling z_context:ensure_session/1, or
%% z_context:ensure_all/1.
-spec set_nocache_headers(z:context()) -> z:context().
set_nocache_headers(Context = #context{cowreq=Req}) when is_map(Req) ->
cowmachine_req:set_resp_headers([
{<<"cache-control">>, <<"no-store, no-cache, must-revalidate, private, post-check=0, pre-check=0">>},
{<<"expires">>, <<"Wed, 10 Dec 2008 14:30:00 GMT">>},
{<<"p3p">>, <<"CP=\"NOI ADM DEV PSAi COM NAV OUR OTRo STP IND DEM\"">>},
{<<"pragma">>, <<"nocache">>}
],
Context).
%% @doc Set security related headers. This can be modified by observing the
%% 'security_headers' notification.
-spec set_security_headers( z:context() ) -> z:context().
set_security_headers(Context) ->
Default = [
% {<<"content-security-policy">>, <<"script-src 'self' 'nonce-'">>}
{<<"x-xss-protection">>, <<"1">>},
{<<"x-content-type-options">>, <<"nosniff">>},
{<<"x-permitted-cross-domain-policies">>, <<"none">>},
{<<"referrer-policy">>, <<"origin-when-cross-origin">>}
],
Default1 = case z_context:get(allow_frame, Context, false) of
true -> Default;
false -> [ {<<"x-frame-options">>, <<"sameorigin">>} | Default ]
end,
HSTSHeaders = case hsts_header(Context) of
{_,_} = H -> [ H | Default1 ];
_ -> Default1
end,
SecurityHeaders = case z_notifier:first(#security_headers{ headers = HSTSHeaders }, Context) of
undefined -> HSTSHeaders;
Custom -> Custom
end,
SecurityHeaders1 = case proplists:get_value(<<"content-security-policy">>, SecurityHeaders) of
undefined ->
SecurityHeaders;
CSPHdr ->
Nonce = csp_nonce(Context),
CSPHdr1 = binary:replace(
CSPHdr,
<<"'nonce-'">>,
<<"'nonce-", Nonce/binary, $'>>),
[
{<<"content-security-policy">>, CSPHdr1}
| proplists:delete(<<"content-security-policy">>, SecurityHeaders)
]
end,
cowmachine_req:set_resp_headers(SecurityHeaders1, Context).
%% @doc Create a hsts header based on the current settings. The result is cached
%% for quick access.
-spec hsts_header( z:context() ) -> undefined | {_, _}.
hsts_header(Context) ->
case z_convert:to_bool(m_config:get_value(site, hsts, false, Context)) of
true ->
F = fun() ->
MaxAge = z_convert:to_integer(m_config:get_value(site, hsts_maxage, ?HSTS_MAXAGE, Context)),
IncludeSubdomains = z_convert:to_bool(m_config:get_value(site, hsts_include_subdomains, false, Context)),
Preload = z_convert:to_bool(m_config:get_value(site, preload, false, Context)),
Options = case {IncludeSubdomains, Preload} of
{true, true} -> <<"; includeSubDomains; preload">>;
{true, _} -> <<"; includeSubDomains">>;
{_, true} -> <<"; preload">>;
{_, _} -> <<"">>
end,
HSTS = iolist_to_binary([ <<"max-age=">>, z_convert:to_binary(MaxAge), Options ]),
{<<"strict-transport-security">>, HSTS}
end,
z_depcache:memo(F, hsts_header, ?DAY, [config], Context);
false ->
undefined
end.
%% @doc Set Cross-Origin Resource Sharing (CORS) headers. The caller must
%% specify default headers to be used in case there are no observers for
%% the #cors_headers{} notification.
-spec set_cors_headers([{binary(), binary()}], z:context()) -> z:context().
set_cors_headers(Default, Context) ->
CorsHeaders = case z_notifier:first(#cors_headers{ headers = Default }, Context) of
undefined -> Default;
Custom -> Custom
end,
set_resp_headers(CorsHeaders, Context).
%% @doc Set the noindex header if the config is set, or the webmachine resource opt is set.
-spec set_noindex_header(z:context()) -> z:context().
set_noindex_header(Context) ->
set_noindex_header(false, Context).
%% @doc Set the noindex header if the config is set, the webmachine resource opt is set or Force is set.
-spec set_noindex_header(Force::term(), z:context()) -> z:context().
set_noindex_header(Force, Context) ->
case z_convert:to_bool(m_config:get_value(seo, noindex, Context))
orelse get(seo_noindex, Context, false)
orelse z_convert:to_bool(Force)
of
true -> set_resp_header(<<"x-robots-tag">>, <<"noindex">>, Context);
_ -> Context
end.
%% @doc Set resource specific headers. Examples are the non-informational resource uri and WebSub headers.
-spec set_resource_headers( m_rsc:resource_id() | undefined, z:context() ) -> z:context().
set_resource_headers(Id, Context) when is_integer(Id) ->
Uri = z_dispatcher:url_for(id, [ {id, Id} ], set_language('x-default', Context)),
Hs = [ {<<"x-resource-uri">>, abs_url(Uri, Context)} ],
Hs1 = z_notifier:foldl(#resource_headers{ id = Id }, Hs, Context),
set_resp_headers(Hs1, Context);
set_resource_headers(_Id, Context) ->
Hs = z_notifier:foldl(#resource_headers{ id = undefined }, [], Context),
set_resp_headers(Hs, Context).
%% @doc Set a cookie value with default options.
-spec set_cookie(binary(), binary(), z:context()) -> z:context().
set_cookie(Key, Value, Context) ->
set_cookie(Key, Value, [], Context).
%% @doc Set a cookie value with cookie options.
-spec set_cookie(binary(), binary(), list(), z:context()) -> z:context().
set_cookie(_Key, _Value, _Options, #context{ cowreq = undefined } = Context) ->
Context;
set_cookie(Key, Value, Options, Context) ->
% Add domain to cookie if not set
ValueBin = z_convert:to_binary(Value),
Options1 = case proplists:lookup(domain, Options) of
{domain, _} -> Options;
none -> [{domain, z_context:cookie_domain(Context)}|Options]
end,
Options2 = [ {secure, true} | proplists:delete(secure, Options1) ],
cowmachine_req:set_resp_cookie(Key, ValueBin, Options2, Context).
%% @doc Read a cookie value from the current request.
-spec get_cookie(binary(), z:context()) -> binary() | undefined.
get_cookie(_Key, #context{cowreq = undefined}) ->
undefined;
get_cookie(Key, #context{cowreq=Req} = Context) when is_map(Req) ->
cowmachine_req:get_cookie_value(Key, Context).
%% @doc Read all cookie values with a certain key from the current request.
-spec get_cookies(binary(), z:context()) -> [ binary() ].
get_cookies(_Key, #context{cowreq = undefined}) ->
[];
get_cookies(Key, #context{cowreq=Req} = Context) when is_map(Req), is_binary(Key) ->
proplists:get_all_values(Key, cowmachine_req:req_cookie(Context)).
%% @doc Set a cookie on the user-agent, holding secret information.
%% The state cookie is used during OAuth key exchanges, against
%% csrf attacks.
-spec set_state_cookie( term(), z:context() ) -> z:context().
set_state_cookie( Data, Context ) ->
Secret = state_cookie_secret(Context),
Encoded = termit:encode_base64(Data, Secret),
Opts = [
{path, <<"/">>},
{http_only, true},
{secure, true},
{same_site, lax}
],
set_cookie(?STATE_SECRET_COOKIE, Encoded, Opts, Context).
%% @doc Get the state cookie and decode it.
-spec get_state_cookie( z:context() ) -> {ok, term()} | {error, term()}.
get_state_cookie(Context) ->
case get_cookie(?STATE_SECRET_COOKIE, Context) of
undefined ->
{error, enoent};
Cookie ->
Secret = state_cookie_secret(Context),
termit:decode_base64(Cookie, Secret)
end.
%% @doc Delete the state cookie.
-spec reset_state_cookie( z:context() ) -> z:context().
reset_state_cookie(Context) ->
Opts = [
{max_age, 0},
{path, <<"/">>},
{http_only, true},
{secure, true},
{same_site, lax}
],
set_cookie(?STATE_SECRET_COOKIE, <<>>, Opts, Context).
%% @doc Return the secret used to encode the state cookie.
-spec state_cookie_secret( z:context() ) -> binary().
state_cookie_secret(Context) ->
case m_config:get_value(site, state_cookie_secret, Context) of
None when None =:= undefined; None =:= <<>> ->
Secret = z_ids:id(32),
m_config:set_value(site, state_cookie_secret, Secret, Context),
Secret;
Secret when is_binary(Secret) ->
Secret
end.