%% @author Marc Worrell %% @copyright 2013-2022 Marc Worrell %% %% @doc Serve a file (possibly resized) %% Copyright 2013-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. %% Serves files like: %% %% /image/2007/03/31/wedding.jpg(300x300)(crop-center)(709a-a3ab6605e5c8ce801ac77eb76289ac12).jpg %% /media/inline/ %% /media/attachment/ -module(controller_file). -export([ service_available/1, allowed_methods/1, resource_exists/1, forbidden/1, last_modified/1, expires/1, content_types_provided/1, charsets_provided/1, content_encodings_provided/1, process/4 ]). -include_lib("zotonic_core/include/zotonic.hrl"). -include_lib("zotonic_core/include/zotonic_file.hrl"). -define(MAX_AGE, 31536000). %% @doc Initialize the context for the request. Optionally continue the user's session. service_available(Context) -> Context1 = z_context:ensure_qs(Context), Context2 = z_context:set_cors_headers([{<<"access-control-allow-origin">>, <<"*">>}], Context1), z_context:logger_md(Context2), case get_file_info(Context2) of {ok, Info} -> IsNoIndex = is_noindex(Info, Context), Context3 = z_context:set_noindex_header(IsNoIndex, Context2), {true, z_context:set(?MODULE, Info, Context3)}; {error, enoent} = Error -> Context3 = z_context:set_noindex_header(Context2), {true, z_context:set(?MODULE, Error, Context3)}; {error, _} = Error -> Context3 = z_context:set_noindex_header(Context2), {false, z_context:set(?MODULE, Error, Context3)} end. allowed_methods(Context) -> {[<<"HEAD">>, <<"GET">>], Context}. resource_exists(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {false, Context}; #z_file_info{acls=Acls} -> {not lists:any(fun(Id) when is_integer(Id) -> not m_rsc:exists(Id, Context); ({module, _Module}) -> false end, Acls), Context} end. forbidden(Context) -> case z_context:get(?MODULE, Context) of {error, _ } -> {false, Context}; #z_file_info{} = FInfo -> Id = get_id(Context), case z_controller_helper:is_authorized(Id, Context) of {false, Context2} -> {true, Context2}; {true, Context2} -> {not z_file_request:is_visible(FInfo, Context2), Context2} end end. last_modified(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {calendar:universal_time(), Context}; #z_file_info{ modifiedUTC = LModUTC } -> {LModUTC, Context} end. expires(Context) -> Date = calendar:universal_time(), case z_context:get(max_age, Context, ?MAX_AGE) of N when N >= ?MAX_AGE -> {z_datetime:next_year(Date), Context}; MaxAge -> NowSecs = calendar:datetime_to_gregorian_seconds(Date), {calendar:gregorian_seconds_to_datetime(NowSecs + MaxAge), Context} end. charsets_provided(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {no_charset, Context}; #z_file_info{ mime = Mime } -> case is_text(Mime) of true -> {[<<"utf-8">>], Context}; _ -> {no_charset, Context} end end. content_encodings_provided(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {[<<"identity">>], Context}; #z_file_info{} = Info -> Encs = z_file_request:content_encodings(Info), {[z_convert:to_binary(Enc)||Enc<-Encs], Context} end. content_types_provided(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {[ {<<"text">>, <<"plain">>, []} ], Context}; #z_file_info{ mime = Mime } -> {[ Mime ], Context} end. process(_Method, _AcceptedCT, _ProvidedCT, Context) -> case z_context:get(?MODULE, Context) of {error, _} -> {<<>>, Context}; #z_file_info{} = Info -> Context1 = set_content_dispostion(z_context:get(content_disposition, Context), Context), MaxAge = z_context:get(max_age, Context1, ?MAX_AGE), Context2 = set_cache_control_public(is_public(Info#z_file_info.acls, Context1), MaxAge, Context1), Context3 = set_content_policy(Info, Context2), Context4 = z_context:set_resource_headers(maybe_id(Context3), Context3), {z_file_request:content_stream(Info, cowmachine_req:resp_content_encoding(Context4)), Context4} end. %%%%% -------------------------- Support functions ------------------------ is_noindex(#z_file_info{acls=Acls}, Context) -> lists:any( fun (Id) when is_integer(Id) -> CatId = m_rsc:p_no_acl(Id, category_id, Context), z_convert:to_bool(m_rsc:p_no_acl(Id, seo_noindex, Context)) orelse z_convert:to_bool(m_rsc:p_no_acl(CatId, is_seo_noindex_cat, Context)); (_) -> false end, Acls). get_id(Context) -> case maybe_id(Context) of false -> undefined; undefined -> undefined; RscId -> RscId end. maybe_id(Context) -> case z_context:get(?MODULE, Context) of {error, _} -> false; #z_file_info{acls=Acls} -> case lists:dropwhile( fun (Id) when is_integer(Id) -> false; (_) -> true end, Acls) of [ Id | _ ] -> Id; [] -> undefined end end. set_content_dispostion(inline, Context) -> z_context:set_resp_header(<<"content-disposition">>, <<"inline">>, Context); set_content_dispostion(attachment, Context) -> z_context:set_resp_header(<<"content-disposition">>, <<"attachment">>, Context); set_content_dispostion(undefined, Context) -> Context. is_public(Ids, Context) -> ContextAnon = z_context:new(Context), is_public(Ids, ContextAnon, true). is_public(_List, _Context, false) -> false; is_public([], _Context, true) -> true; is_public([{module, Mod}|T], Context, _Answer) -> is_public(T, Context, z_acl:is_allowed(use, Mod, Context)); is_public([Id|T], Context, _Answer) -> is_public(T, Context, z_acl:rsc_visible(Id, Context)). %% @doc Files that are uploaded get a strict content-security-policy. %% Controlled files from the file system are not restricted. set_content_policy(#z_file_info{acls=[]}, Context) -> Context; set_content_policy(#z_file_info{ mime = Mime } = Info, Context) -> IsPlayerNeeded = is_player_needed(Mime), case is_resource(Info) of true when IsPlayerNeeded -> z_context:set_resp_headers([ {<<"content-security-policy">>, <<"default-src 'none'; media-src 'self'; object-src 'self'">>}, {<<"x-content-security-policy">>, <<"default-src 'none'; media-src 'self'; object-src 'self'; plugin-types: application/pdf">>} ], Context); true -> % Do not set the IE11 X-CSP with sandbox as that disables file downloading % https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox z_context:set_resp_headers([ {<<"content-security-policy">>, <<"default-src 'none'; sandbox">>}, {<<"x-content-security-policy">>, <<"default-src 'none'">>} ], Context); false -> Context end. is_player_needed(<<"application/pdf">>) -> true; is_player_needed(<<"video/", _/binary>>) -> true; is_player_needed(<<"audio/", _/binary>>) -> true; is_player_needed(_) -> false. %% @doc Check if the served file originated from an user-upload (ie. it is a resource) is_resource( #z_file_info{ acls = Acls }) -> lists:any(fun is_integer/1, Acls). %% @doc Allow caching on public data, no caching on data that needs access control. set_cache_control_public(true, MaxAge, Context) -> z_context:set_resp_header( <<"cache-control">>, <<"public, max-age=", (z_convert:to_binary(MaxAge))/binary>>, Context); set_cache_control_public(false, _MaxAge, Context) -> z_context:set_resp_header( <<"cache-control">>, <<"private, max-age=0, must-revalidate, post-check=0, pre-check=0">>, Context). get_file_info(Context) -> get_file_info_cfg(z_context:get(path, Context), Context). get_file_info_cfg(undefined, Context) -> DispPath = drop_dotdot(maybe_urldecode(rootless(cowmachine_req:disp_path(Context)))), get_file_info_path(DispPath, Context); get_file_info_cfg(id, _Context) -> ?LOG_ERROR("controller_file does not support the 'id' config, use controller_file_id instead."), {error, enoent}; get_file_info_cfg(ConfiguredPath, Context) when is_list(ConfiguredPath); is_binary(ConfiguredPath) -> get_file_info_path(rootless(z_convert:to_binary(ConfiguredPath)), Context). get_file_info_path(Path, Context) when is_binary(Path) -> Root = z_context:get(root, Context), OptFilters = z_context:get(media_tag_url2props, Context, undefined), z_file_request:lookup_file(Path, Root, OptFilters, z_context:set(path, Path, Context)). %% @doc Ensure path is non-root rootless(undefined) -> <<>>; rootless(<<"/", Rest/binary>>) -> rootless(Rest); rootless(<<"./", Rest/binary>>) -> rootless(Rest); rootless(<<"../", Rest/binary>>) -> rootless(Rest); rootless(B) when is_binary(B) -> B. maybe_urldecode(Path) -> case binary:match(Path, <<"%">>) of nomatch -> Path; {_,_} -> rootless(cow_qs:urldecode(Path)) end. drop_dotdot(Path) -> case binary:match(Path, <<"../">>) =/= nomatch orelse binary:match(Path, <<"./">>) =/= nomatch of false -> Path; true -> Parts = binary:split(Path, <<"/">>, [global]), Parts1 = drop_dotdot_1(Parts, []), rootless(iolist_to_binary(lists:join($/, Parts1))) end. drop_dotdot_1([], Acc) -> lists:reverse(Acc); drop_dotdot_1([_Dir,<<"..">>|Rest], Acc) -> drop_dotdot_1(Rest, Acc); drop_dotdot_1([<<"..">>|Rest], Acc) -> drop_dotdot_1(Rest, Acc); drop_dotdot_1([<<".">>|Rest], Acc) -> drop_dotdot_1(Rest, Acc); drop_dotdot_1([Other|Rest], Acc) -> drop_dotdot_1(Rest, [Other|Acc]). %% @doc Check if a file is text based (influences the provided character sets) is_text(<<"text/", _/binary>>) -> true; is_text(<<"application/x-javascript">>) -> true; is_text(<<"application/javascript">>) -> true; is_text(<<"application/xhtml+xml">>) -> true; is_text(<<"application/xml">>) -> true; is_text(<<"application/json">>) -> true; is_text(<<"application/ld+json">>) -> true; is_text(_Mime) -> false.