/modules/mod_base/resources/resource_file_readonly.erl

http://github.com/zotonic/zotonic · Erlang · 452 lines · 356 code · 48 blank · 48 comment · 0 complexity · df367c9d5e160ae9d5f95557f8a22668 MD5 · raw file

  1. %% @author Marc Worrell <marc@worrell.nl>
  2. %% @copyright 2009-2010 Marc Worrell
  3. %%
  4. %% @doc Serve static (image) files from a configured list of directories or template lookup keys. Caches files in the local depcache.
  5. %% Is also able to generate previews (if configured to do so).
  6. %% Copyright 2009-2010 Marc Worrell
  7. %%
  8. %% Licensed under the Apache License, Version 2.0 (the "License");
  9. %% you may not use this file except in compliance with the License.
  10. %% You may obtain a copy of the License at
  11. %%
  12. %% http://www.apache.org/licenses/LICENSE-2.0
  13. %%
  14. %% Unless required by applicable law or agreed to in writing, software
  15. %% distributed under the License is distributed on an "AS IS" BASIS,
  16. %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  17. %% See the License for the specific language governing permissions and
  18. %% limitations under the License.
  19. %% Serves files like:
  20. %%
  21. %% /image/2007/03/31/wedding.jpg(300x300)(crop-center)(709a-a3ab6605e5c8ce801ac77eb76289ac12).jpg
  22. %% /media/inline/<filepath>
  23. %% /media/attachment/<filepath>
  24. -module(resource_file_readonly).
  25. -export([
  26. init/1,
  27. service_available/2,
  28. allowed_methods/2,
  29. resource_exists/2,
  30. forbidden/2,
  31. last_modified/2,
  32. expires/2,
  33. content_types_provided/2,
  34. charsets_provided/2,
  35. encodings_provided/2,
  36. provide_content/2,
  37. finish_request/2
  38. ]).
  39. -include_lib("webmachine_resource.hrl").
  40. -include_lib("zotonic.hrl").
  41. -record(cache, {path, fullpath, mime, last_modified, body}).
  42. -define(MAX_AGE, 315360000).
  43. -define(CHUNKED_CONTENT_LENGTH, 1048576).
  44. -define(CHUNK_LENGTH, 65536).
  45. init(ConfigProps) ->
  46. {ok, ConfigProps}.
  47. %% @doc Initialize the context for the request. Continue session when available.
  48. service_available(ReqData, ConfigProps) ->
  49. Context = z_context:set(ConfigProps, z_context:new(ReqData)),
  50. Context1 = z_context:ensure_qs(z_context:continue_session(Context)),
  51. try ensure_file_info(ReqData, Context1) of
  52. {_, ContextFile} ->
  53. % Use chunks for large files
  54. case z_context:get(fullpath, ContextFile) of
  55. undefined ->
  56. ?WM_REPLY(true, ContextFile);
  57. FullPath ->
  58. case catch filelib:file_size(FullPath) of
  59. N when is_integer(N) ->
  60. case N > ?CHUNKED_CONTENT_LENGTH of
  61. true ->
  62. ContextChunked = z_context:set([{chunked, true}, {file_size, N}], ContextFile),
  63. ?WM_REPLY(true, ContextChunked);
  64. false ->
  65. ContextSize = z_context:set([{file_size, N}], ContextFile),
  66. ?WM_REPLY(true, ContextSize)
  67. end;
  68. _ ->
  69. ?WM_REPLY(true, ContextFile)
  70. end
  71. end
  72. catch
  73. _:checksum_invalid ->
  74. %% Not a nice solution, but since 'resource_exists'
  75. %% are checked much later in the wm flow, we would otherwise
  76. %% have to break the logical flow, and introduce some ugly
  77. %% condition checking in the intermediate callback functions.
  78. ?WM_REPLY(false, Context1)
  79. end.
  80. allowed_methods(ReqData, Context) ->
  81. {['HEAD', 'GET'], ReqData, Context}.
  82. content_types_provided(ReqData, Context) ->
  83. {[{z_context:get(mime, Context), provide_content}], ReqData, Context}.
  84. %% @doc Simple access control for rsc based files
  85. forbidden(ReqData, Context) ->
  86. case z_context:get(id, Context) of
  87. undefined ->
  88. case z_context:get(root, Context) of
  89. [{module, Module}] ->
  90. {Module:file_forbidden(z_context:get(fullpath, Context), Context), ReqData, Context};
  91. _ ->
  92. {false, ReqData, Context}
  93. end;
  94. RscId when is_integer(RscId) ->
  95. {not z_acl:rsc_visible(RscId, Context), ReqData, Context}
  96. end.
  97. encodings_provided(ReqData, Context) ->
  98. Encodings = case z_context:get(chunked, Context) of
  99. true ->
  100. [{"identity", fun(Data) -> Data end}];
  101. _ ->
  102. case z_context:get(mime, Context) of
  103. "image/" ++ _ -> [{"identity", fun(Data) -> Data end}];
  104. "video/" ++ _ -> [{"identity", fun(Data) -> Data end}];
  105. "audio/" ++ _ -> [{"identity", fun(Data) -> Data end}];
  106. "application/x-gzip" ++ _ -> [{"identity", fun(Data) -> Data end}];
  107. "application/zip" ++ _ -> [{"identity", fun(Data) -> Data end}];
  108. _ ->
  109. [{"identity", fun(Data) -> decode_data(identity, Data) end},
  110. {"gzip", fun(Data) -> decode_data(gzip, Data) end}]
  111. end
  112. end,
  113. {Encodings, ReqData, z_context:set(encode_data, length(Encodings) > 1, Context)}.
  114. resource_exists(ReqData, Context) ->
  115. {z_context:get(fullpath, Context) =/= undefined, ReqData, Context}.
  116. charsets_provided(ReqData, Context) ->
  117. case is_text(z_context:get(mime, Context)) of
  118. true -> {[{"utf-8", fun(X) -> X end}], ReqData, Context};
  119. _ -> {no_charset, ReqData, Context}
  120. end.
  121. last_modified(ReqData, Context) ->
  122. RD1 = case z_context:get(id, Context) of
  123. undefined ->
  124. wrq:set_resp_header("Cache-Control", "public, max-age="++integer_to_list(?MAX_AGE), ReqData);
  125. RscId when is_integer(RscId) ->
  126. case is_public(RscId, Context) of
  127. true ->
  128. % Public
  129. wrq:set_resp_header("Cache-Control", "public, max-age="++integer_to_list(?MAX_AGE), ReqData);
  130. false ->
  131. % Not public
  132. wrq:set_resp_header("Cache-Control", "private, max-age=0, must-revalidate, post-check=0, pre-check=0", ReqData)
  133. end
  134. end,
  135. case z_context:get(last_modified, Context) of
  136. undefined ->
  137. LMod = filelib:last_modified(z_context:get(fullpath, Context)),
  138. [LModUTC|_] = calendar:local_time_to_universal_time_dst(LMod),
  139. {LModUTC, RD1, z_context:set(last_modified, LModUTC, Context)};
  140. LModUTC ->
  141. {LModUTC, RD1, Context}
  142. end.
  143. expires(ReqData, Context) ->
  144. NowSecs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
  145. {calendar:gregorian_seconds_to_datetime(NowSecs + ?MAX_AGE), ReqData, Context}.
  146. provide_content(ReqData, Context) ->
  147. RD1 = case z_context:get(content_disposition, Context) of
  148. inline -> wrq:set_resp_header("Content-Disposition", "inline", ReqData);
  149. attachment -> wrq:set_resp_header("Content-Disposition", "attachment", ReqData);
  150. undefined -> ReqData
  151. end,
  152. case z_context:get(body, Context) of
  153. undefined ->
  154. case z_context:get(chunked, Context) of
  155. true ->
  156. {ok, Device} = file:open(z_context:get(fullpath, Context), [read,raw,binary]),
  157. FileSize = z_context:get(file_size, Context),
  158. { {stream, read_chunk(0, FileSize, Device)},
  159. wrq:set_resp_header("Content-Length", integer_to_list(FileSize), RD1),
  160. z_context:set(use_cache, false, Context) };
  161. _ ->
  162. {ok, Data} = file:read_file(z_context:get(fullpath, Context)),
  163. Body = case z_context:get(encode_data, Context, false) of
  164. true -> encode_data(Data);
  165. false -> Data
  166. end,
  167. {Body, RD1, z_context:set(body, Body, Context)}
  168. end;
  169. Body ->
  170. {Body, RD1, Context}
  171. end.
  172. read_chunk(Offset, Size, Device) when Offset =:= Size ->
  173. file:close(Device),
  174. {<<>>, done};
  175. read_chunk(Offset, Size, Device) when Size - Offset =< ?CHUNK_LENGTH ->
  176. {ok, Data} = file:read(Device, Size - Offset),
  177. file:close(Device),
  178. {Data, done};
  179. read_chunk(Offset, Size, Device) ->
  180. {ok, Data} = file:read(Device, ?CHUNK_LENGTH),
  181. {Data, fun() -> read_chunk(Offset+?CHUNK_LENGTH, Size, Device) end}.
  182. finish_request(ReqData, Context) ->
  183. case z_context:get(is_cached, Context) of
  184. false ->
  185. case z_context:get(body, Context) of
  186. undefined ->
  187. {ok, ReqData, Context};
  188. Body ->
  189. case z_context:get(use_cache, Context, false) andalso z_context:get(encode_data, Context, false) of
  190. true ->
  191. % Cache the served file in the depcache. Cache it for 3600 secs.
  192. Path = z_context:get(path, Context),
  193. Cache = #cache{
  194. path=Path,
  195. fullpath=z_context:get(fullpath, Context),
  196. mime=z_context:get(mime, Context),
  197. last_modified=z_context:get(last_modified, Context),
  198. body=Body
  199. },
  200. z_depcache:set(cache_key(Path), Cache, Context),
  201. {ok, ReqData, Context};
  202. _ ->
  203. % No cache or no gzip'ed version (file system cache is fast enough for image serving)
  204. {ok, ReqData, Context}
  205. end
  206. end;
  207. true ->
  208. {ok, ReqData, Context}
  209. end.
  210. %%%%%%%%%%%%%% Helper functions %%%%%%%%%%%%%%
  211. %% @doc Find the file referred to by the reqdata or the preconfigured path
  212. ensure_file_info(ReqData, Context) ->
  213. {Path, ContextPath} = case z_context:get(path, Context) of
  214. undefined ->
  215. FilePath = mochiweb_util:safe_relative_path(mochiweb_util:unquote(wrq:disp_path(ReqData))),
  216. rsc_media_check(FilePath, Context);
  217. id ->
  218. RscId = m_rsc:rid(z_context:get_q("id", Context), Context),
  219. ContextRsc = z_context:set(id, RscId, Context),
  220. case m_media:get(RscId, ContextRsc) of
  221. undefined ->
  222. {undefined, ContextRsc};
  223. Media ->
  224. {z_convert:to_list(proplists:get_value(filename, Media)),
  225. z_context:set(mime, z_convert:to_list(proplists:get_value(mime, Media)), ContextRsc)}
  226. end;
  227. ConfiguredPath ->
  228. {ConfiguredPath, Context}
  229. end,
  230. Cached = case z_context:get(use_cache, ContextPath) of
  231. true -> z_depcache:get(cache_key(Path), ContextPath);
  232. _ -> undefined
  233. end,
  234. case Cached of
  235. undefined ->
  236. ContextMime = case z_context:get(mime, ContextPath) of
  237. undefined -> z_context:set(mime, z_media_identify:guess_mime(Path), ContextPath);
  238. _Mime -> ContextPath
  239. end,
  240. case file_exists(Path, ContextMime) of
  241. {true, FullPath} ->
  242. {true, z_context:set([ {path, Path}, {fullpath, FullPath} ], ContextMime)};
  243. _ ->
  244. %% We might be able to generate a new preview
  245. case z_context:get(is_media_preview, ContextMime, false) of
  246. true ->
  247. % Generate a preview, recurse on success
  248. ensure_preview(Path, ContextMime);
  249. false ->
  250. {false, ContextMime}
  251. end
  252. end;
  253. {ok, Cache} ->
  254. {true, z_context:set([ {is_cached, true},
  255. {path, Cache#cache.path},
  256. {fullpath, Cache#cache.fullpath},
  257. {mime, Cache#cache.mime},
  258. {last_modified, Cache#cache.last_modified},
  259. {body, Cache#cache.body}
  260. ],
  261. ContextPath)}
  262. end.
  263. rsc_media_check(undefined, Context) ->
  264. {undefined, Context};
  265. rsc_media_check(File, Context) ->
  266. {BaseFile, IsResized, Context1} = case lists:member($(, File) of
  267. true ->
  268. {File1, Proplists, Check, Prop} = z_media_tag:url2props(File, Context),
  269. {File1, true, z_context:set(media_tag_url2props, {File1, Proplists, Check, Prop}, Context)};
  270. false ->
  271. {File, false, Context}
  272. end,
  273. case m_media:get_by_filename(BaseFile, Context1) of
  274. undefined ->
  275. {File, Context1};
  276. Media ->
  277. MimeOriginal = z_convert:to_list(proplists:get_value(mime, Media)),
  278. Props = [
  279. {id, proplists:get_value(id, Media)},
  280. {mime_original, MimeOriginal}
  281. ],
  282. Props1 = case IsResized of
  283. true -> [ {mime, z_media_identify:guess_mime(File)} | Props ];
  284. false -> [ {mime, MimeOriginal} | Props ]
  285. end,
  286. {File, z_context:set(Props1, Context1)}
  287. end.
  288. cache_key(Path) ->
  289. {resource_file, Path}.
  290. file_exists(undefined, _Context) ->
  291. false;
  292. file_exists([], _Context) ->
  293. false;
  294. file_exists(Name, Context) ->
  295. RelName = case hd(Name) of
  296. $/ -> tl(Name);
  297. _ -> Name
  298. end,
  299. case mochiweb_util:safe_relative_path(RelName) of
  300. undefined -> false;
  301. SafePath ->
  302. RelName = case hd(SafePath) of
  303. "/" -> tl(SafePath);
  304. _ -> SafePath
  305. end,
  306. Root = case z_context:get(root, Context) of
  307. undefined ->
  308. case z_context:get(is_media_preview, Context, false) of
  309. true -> [z_path:media_preview(Context)];
  310. false -> [z_path:media_archive(Context)]
  311. end;
  312. ConfRoot -> ConfRoot
  313. end,
  314. file_exists1(Root, RelName, Context)
  315. end.
  316. file_exists1([], _RelName, _Context) ->
  317. false;
  318. file_exists1([ModuleIndex|T], RelName, Context) when is_atom(ModuleIndex) ->
  319. case z_module_indexer:find(ModuleIndex, RelName, Context) of
  320. {ok, File} -> {true, File};
  321. {error, _} -> file_exists1(T, RelName, Context)
  322. end;
  323. file_exists1([{module, Module}|T], RelName, Context) ->
  324. case Module:file_exists(RelName, Context) of
  325. false -> file_exists1(T, RelName, Context);
  326. Result -> Result
  327. end;
  328. file_exists1([DirName|T], RelName, Context) ->
  329. NamePath = filename:join([DirName,RelName]),
  330. case filelib:is_regular(NamePath) of
  331. true ->
  332. {true, NamePath};
  333. false ->
  334. file_exists1(T, RelName, Context)
  335. end.
  336. %% @spec is_text(Mime) -> bool()
  337. %% @doc Check if a mime type is textual
  338. is_text("text/" ++ _) -> true;
  339. is_text("application/x-javascript") -> true;
  340. is_text("application/xhtml+xml") -> true;
  341. is_text("application/xml") -> true;
  342. is_text(_Mime) -> false.
  343. %% @spec ensure_preview(Path, Context) -> {Boolean, NewContext}
  344. %% @doc Generate the file on the path from an archived media file.
  345. %% The path is like: 2007/03/31/wedding.jpg(300x300)(crop-center)(709a-a3ab6605e5c8ce801ac77eb76289ac12).jpg
  346. %% The original media should be in State#media_path (or z_path:media_archive)
  347. %% The generated image should be created in State#root (or z_path:media_preview)
  348. ensure_preview(Path, Context) ->
  349. UrlProps = case z_context:get(media_tag_url2props,Context) of
  350. undefined -> z_media_tag:url2props(Path, Context);
  351. MediaInfo -> MediaInfo
  352. end,
  353. case UrlProps of
  354. error ->
  355. {false, Context};
  356. {Filepath, PreviewPropList, _Checksum, _ChecksumBaseString} ->
  357. case mochiweb_util:safe_relative_path(Filepath) of
  358. undefined ->
  359. {false, Context};
  360. Safepath ->
  361. MediaPath = case z_context:get(media_path, Context) of
  362. undefined -> z_path:media_archive(Context);
  363. ConfMediaPath -> ConfMediaPath
  364. end,
  365. MediaFile = case Safepath of
  366. "lib/" ++ LibPath ->
  367. case z_module_indexer:find(lib, LibPath, Context) of
  368. {ok, ModuleFilename} -> ModuleFilename;
  369. {error, _} -> filename:join(MediaPath, Safepath)
  370. end;
  371. _ ->
  372. filename:join(MediaPath, Safepath)
  373. end,
  374. case filelib:is_regular(MediaFile) of
  375. true ->
  376. % Media file exists, perform the resize
  377. Root = case z_context:get(root, Context) of
  378. [ConfRoot|_] -> ConfRoot;
  379. _ -> z_path:media_preview(Context)
  380. end,
  381. PreviewFile = filename:join(Root, Path),
  382. case z_media_preview:convert(MediaFile, PreviewFile, PreviewPropList, Context) of
  383. ok -> {true, z_context:set(fullpath, PreviewFile, Context)};
  384. {error, Reason} -> throw(Reason)
  385. end;
  386. false ->
  387. {false, Context}
  388. end
  389. end
  390. end.
  391. %% Encode the data so that the identity variant comes first and then the gzip'ed variant
  392. encode_data(Data) when is_binary(Data) ->
  393. {Data, zlib:gzip(Data)}.
  394. decode_data(gzip, Data) when is_binary(Data) ->
  395. zlib:gzip(Data);
  396. decode_data(identity, Data) when is_binary(Data) ->
  397. Data;
  398. decode_data(identity, {Data, _Gzip}) ->
  399. Data;
  400. decode_data(gzip, {_Data, Gzip}) ->
  401. Gzip.
  402. %% @doc Check if a resource is publicly viewable
  403. is_public(RscId, Context) ->
  404. z_acl:rsc_visible(RscId, z_context:new(Context)).