base_resource.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2014 OpenMarket Ltd
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. from .thumbnailer import Thumbnailer
  16. from synapse.http.server import respond_with_json
  17. from synapse.util.stringutils import random_string
  18. from synapse.api.errors import (
  19. cs_exception, CodeMessageException, cs_error, Codes, SynapseError
  20. )
  21. from twisted.internet import defer
  22. from twisted.web.resource import Resource
  23. from twisted.protocols.basic import FileSender
  24. import os
  25. import logging
  26. logger = logging.getLogger(__name__)
  27. class BaseMediaResource(Resource):
  28. isLeaf = True
  29. def __init__(self, hs, filepaths):
  30. Resource.__init__(self)
  31. self.auth = hs.get_auth()
  32. self.client = hs.get_http_client()
  33. self.clock = hs.get_clock()
  34. self.server_name = hs.hostname
  35. self.store = hs.get_datastore()
  36. self.max_upload_size = hs.config.max_upload_size
  37. self.max_image_pixels = hs.config.max_image_pixels
  38. self.filepaths = filepaths
  39. self.downloads = {}
  40. @staticmethod
  41. def catch_errors(request_handler):
  42. @defer.inlineCallbacks
  43. def wrapped_request_handler(self, request):
  44. try:
  45. yield request_handler(self, request)
  46. except CodeMessageException as e:
  47. logger.exception(e)
  48. respond_with_json(
  49. request, e.code, cs_exception(e), send_cors=True
  50. )
  51. except:
  52. logger.exception(
  53. "Failed handle request %s.%s on %r",
  54. request_handler.__module__,
  55. request_handler.__name__,
  56. self,
  57. )
  58. respond_with_json(
  59. request,
  60. 500,
  61. {"error": "Internal server error"},
  62. send_cors=True
  63. )
  64. return wrapped_request_handler
  65. @staticmethod
  66. def _parse_media_id(request):
  67. try:
  68. server_name, media_id = request.postpath
  69. return (server_name, media_id)
  70. except:
  71. raise SynapseError(
  72. 404,
  73. "Invalid media id token %r" % (request.postpath,),
  74. Codes.UNKKOWN,
  75. )
  76. @staticmethod
  77. def _parse_integer(request, arg_name, default=None):
  78. try:
  79. if default is None:
  80. return int(request.args[arg_name][0])
  81. else:
  82. return int(request.args.get(arg_name, [default])[0])
  83. except:
  84. raise SynapseError(
  85. 400,
  86. "Missing integer argument %r" % (arg_name,),
  87. Codes.UNKNOWN,
  88. )
  89. @staticmethod
  90. def _parse_string(request, arg_name, default=None):
  91. try:
  92. if default is None:
  93. return request.args[arg_name][0]
  94. else:
  95. return request.args.get(arg_name, [default])[0]
  96. except:
  97. raise SynapseError(
  98. 400,
  99. "Missing string argument %r" % (arg_name,),
  100. Codes.UNKNOWN,
  101. )
  102. def _respond_404(self, request):
  103. respond_with_json(
  104. request, 404,
  105. cs_error(
  106. "Not found %r" % (request.postpath,),
  107. code=Codes.NOT_FOUND,
  108. ),
  109. send_cors=True
  110. )
  111. @staticmethod
  112. def _makedirs(filepath):
  113. dirname = os.path.dirname(filepath)
  114. if not os.path.exists(dirname):
  115. os.makedirs(dirname)
  116. def _get_remote_media(self, server_name, media_id):
  117. key = (server_name, media_id)
  118. download = self.downloads.get(key)
  119. if download is None:
  120. download = self._get_remote_media_impl(server_name, media_id)
  121. self.downloads[key] = download
  122. @download.addBoth
  123. def callback(media_info):
  124. del self.downloads[key]
  125. return download
  126. @defer.inlineCallbacks
  127. def _get_remote_media_impl(self, server_name, media_id):
  128. media_info = yield self.store.get_cached_remote_media(
  129. server_name, media_id
  130. )
  131. if not media_info:
  132. media_info = yield self._download_remote_file(
  133. server_name, media_id
  134. )
  135. defer.returnValue(media_info)
  136. @defer.inlineCallbacks
  137. def _download_remote_file(self, server_name, media_id):
  138. file_id = random_string(24)
  139. fname = self.filepaths.remote_media_filepath(
  140. server_name, file_id
  141. )
  142. self._makedirs(fname)
  143. try:
  144. with open(fname, "wb") as f:
  145. request_path = "/".join((
  146. "/_matrix/media/v1/download", server_name, media_id,
  147. ))
  148. length, headers = yield self.client.get_file(
  149. server_name, request_path, output_stream=f,
  150. max_size=self.max_upload_size,
  151. )
  152. media_type = headers["Content-Type"][0]
  153. time_now_ms = self.clock.time_msec()
  154. yield self.store.store_cached_remote_media(
  155. origin=server_name,
  156. media_id=media_id,
  157. media_type=media_type,
  158. time_now_ms=self.clock.time_msec(),
  159. upload_name=None,
  160. media_length=length,
  161. filesystem_id=file_id,
  162. )
  163. except:
  164. os.remove(fname)
  165. raise
  166. media_info = {
  167. "media_type": media_type,
  168. "media_length": length,
  169. "upload_name": None,
  170. "created_ts": time_now_ms,
  171. "filesystem_id": file_id,
  172. }
  173. yield self._generate_remote_thumbnails(
  174. server_name, media_id, media_info
  175. )
  176. defer.returnValue(media_info)
  177. @defer.inlineCallbacks
  178. def _respond_with_file(self, request, media_type, file_path):
  179. logger.debug("Responding with %r", file_path)
  180. if os.path.isfile(file_path):
  181. request.setHeader(b"Content-Type", media_type.encode("UTF-8"))
  182. # cache for at least a day.
  183. # XXX: we might want to turn this off for data we don't want to
  184. # recommend caching as it's sensitive or private - or at least
  185. # select private. don't bother setting Expires as all our
  186. # clients are smart enough to be happy with Cache-Control
  187. request.setHeader(
  188. b"Cache-Control", b"public,max-age=86400,s-maxage=86400"
  189. )
  190. with open(file_path, "rb") as f:
  191. yield FileSender().beginFileTransfer(f, request)
  192. request.finish()
  193. else:
  194. self._respond_404()
  195. def _get_thumbnail_requirements(self, media_type):
  196. if media_type == "image/jpeg":
  197. return (
  198. (32, 32, "crop", "image/jpeg"),
  199. (96, 96, "crop", "image/jpeg"),
  200. (320, 240, "scale", "image/jpeg"),
  201. (640, 480, "scale", "image/jpeg"),
  202. )
  203. elif (media_type == "image/png") or (media_type == "image/gif"):
  204. return (
  205. (32, 32, "crop", "image/png"),
  206. (96, 96, "crop", "image/png"),
  207. (320, 240, "scale", "image/png"),
  208. (640, 480, "scale", "image/png"),
  209. )
  210. else:
  211. return ()
  212. @defer.inlineCallbacks
  213. def _generate_local_thumbnails(self, media_id, media_info):
  214. media_type = media_info["media_type"]
  215. requirements = self._get_thumbnail_requirements(media_type)
  216. if not requirements:
  217. return
  218. input_path = self.filepaths.local_media_filepath(media_id)
  219. thumbnailer = Thumbnailer(input_path)
  220. m_width = thumbnailer.width
  221. m_height = thumbnailer.height
  222. if m_width * m_height >= self.max_image_pixels:
  223. logger.info(
  224. "Image too large to thumbnail %r x %r > %r",
  225. m_width, m_height, self.max_image_pixels
  226. )
  227. return
  228. scales = set()
  229. crops = set()
  230. for r_width, r_height, r_method, r_type in requirements:
  231. if r_method == "scale":
  232. t_width, t_height = thumbnailer.aspect(r_width, r_height)
  233. scales.add((
  234. min(m_width, t_width), min(m_height, t_height), r_type,
  235. ))
  236. elif r_method == "crop":
  237. crops.add((r_width, r_height, r_type))
  238. for t_width, t_height, t_type in scales:
  239. t_method = "scale"
  240. t_path = self.filepaths.local_media_thumbnail(
  241. media_id, t_width, t_height, t_type, t_method
  242. )
  243. self._makedirs(t_path)
  244. t_len = thumbnailer.scale(t_path, t_width, t_height, t_type)
  245. yield self.store.store_local_thumbnail(
  246. media_id, t_width, t_height, t_type, t_method, t_len
  247. )
  248. for t_width, t_height, t_type in crops:
  249. if (t_width, t_height, t_type) in scales:
  250. # If the aspect ratio of the cropped thumbnail matches a purely
  251. # scaled one then there is no point in calculating a separate
  252. # thumbnail.
  253. continue
  254. t_method = "crop"
  255. t_path = self.filepaths.local_media_thumbnail(
  256. media_id, t_width, t_height, t_type, t_method
  257. )
  258. self._makedirs(t_path)
  259. t_len = thumbnailer.crop(t_path, t_width, t_height, t_type)
  260. yield self.store.store_local_thumbnail(
  261. media_id, t_width, t_height, t_type, t_method, t_len
  262. )
  263. defer.returnValue({
  264. "width": m_width,
  265. "height": m_height,
  266. })
  267. @defer.inlineCallbacks
  268. def _generate_remote_thumbnails(self, server_name, media_id, media_info):
  269. media_type = media_info["media_type"]
  270. file_id = media_info["filesystem_id"]
  271. requirements = self._get_thumbnail_requirements(media_type)
  272. if not requirements:
  273. return
  274. input_path = self.filepaths.remote_media_filepath(server_name, file_id)
  275. thumbnailer = Thumbnailer(input_path)
  276. m_width = thumbnailer.width
  277. m_height = thumbnailer.height
  278. if m_width * m_height >= self.max_image_pixels:
  279. logger.info(
  280. "Image too large to thumbnail %r x %r > %r",
  281. m_width, m_height, self.max_image_pixels
  282. )
  283. return
  284. scales = set()
  285. crops = set()
  286. for r_width, r_height, r_method, r_type in requirements:
  287. if r_method == "scale":
  288. t_width, t_height = thumbnailer.aspect(r_width, r_height)
  289. scales.add((
  290. min(m_width, t_width), min(m_height, t_height), r_type,
  291. ))
  292. elif r_method == "crop":
  293. crops.add((r_width, r_height, r_type))
  294. for t_width, t_height, t_type in scales:
  295. t_method = "scale"
  296. t_path = self.filepaths.remote_media_thumbnail(
  297. server_name, file_id, t_width, t_height, t_type, t_method
  298. )
  299. self._makedirs(t_path)
  300. t_len = thumbnailer.scale(t_path, t_width, t_height, t_type)
  301. yield self.store.store_remote_media_thumbnail(
  302. server_name, media_id, file_id,
  303. t_width, t_height, t_type, t_method, t_len
  304. )
  305. for t_width, t_height, t_type in crops:
  306. if (t_width, t_height, t_type) in scales:
  307. # If the aspect ratio of the cropped thumbnail matches a purely
  308. # scaled one then there is no point in calculating a separate
  309. # thumbnail.
  310. continue
  311. t_method = "crop"
  312. t_path = self.filepaths.remote_media_thumbnail(
  313. server_name, file_id, t_width, t_height, t_type, t_method
  314. )
  315. self._makedirs(t_path)
  316. t_len = thumbnailer.crop(t_path, t_width, t_height, t_type)
  317. yield self.store.store_remote_media_thumbnail(
  318. server_name, media_id, file_id,
  319. t_width, t_height, t_type, t_method, t_len
  320. )
  321. defer.returnValue({
  322. "width": m_width,
  323. "height": m_height,
  324. })