server.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import base64, uuid
  2. import datetime
  3. import hashlib
  4. import os
  5. import subprocess
  6. from subprocess import Popen, PIPE
  7. import tornado.ioloop
  8. import tornado.web
  9. from tornado.escape import url_escape
  10. from tornado.options import define, options
  11. from cgi import escape as htmlspecialchars
  12. import xattr
  13. import logging
  14. log = logging.getLogger(__name__)
  15. log.setLevel(logging.DEBUG)
  16. class KonvertHandler(tornado.web.RequestHandler):
  17. def writeln(self, *args, **kwargs):
  18. super().write(*args, **kwargs)
  19. self.write("\n")
  20. class DownloadHandler(tornado.web.StaticFileHandler):
  21. def get(self, *args, head=False, **kwargs):
  22. with open(path, 'rb') as f:
  23. self.set_header('Expires', datetime.datetime.utcnow() + datetime.timedelta(1000000))
  24. try:
  25. mimetype = xattr.get(f, 'user.mime_type').decode('utf-8')
  26. log.debug('Found mime_type in xattrs')
  27. self.set_header('Content-Type', mimetype)
  28. except OSError:
  29. pass
  30. try:
  31. orig_filename = xattr.get(f, 'user.filename').decode('utf-8')
  32. self.set_header('Content-Disposition',' inline; filename="{}"'.format(url_escape(orig_filename, plus=False)))
  33. except OSError:
  34. pass
  35. self.set_header('Content-Length',os.stat(f.fileno()).st_size)
  36. if head:
  37. self.finish()
  38. return
  39. return super().get(*args, **kwargs)
  40. def head(self, *args, **kwargs):
  41. return self.get(*args, head=True, **kwargs)
  42. class IndexHandler(KonvertHandler):
  43. def get(self):
  44. log.debug
  45. @tornado.web.stream_request_body
  46. class MainHandler(KonvertHandler):
  47. expect_hash = None
  48. def sha512_existed(self):
  49. self.load_uuid_by_hash('sha512', self.sha512sum)
  50. with open(os.path.join(self._metadatadir, 'filenames'), 'a') as f:
  51. ''' Append the new filename (maybe check for dupes in the future? '''
  52. f.write(self._filename + "\n")
  53. self.report_back()
  54. self.finish()
  55. def initialize(self):
  56. log.debug('Initializing variables')
  57. self.recv_bytes = 0
  58. self.sha512 = hashlib.sha512()
  59. self._sha512dir = os.path.join('uploads', 'by-hash', 'sha512')
  60. os.makedirs(self._sha512dir, exist_ok=True)
  61. def prepare(self):
  62. supplied_hash = self.request.headers.get('X-Body-Hash')
  63. if supplied_hash:
  64. ''' should be able to handle something like: hexdigest; algo=sha512'''
  65. hexdigest = None
  66. algo = None # default
  67. for part in supplised_hash.split(';'):
  68. if hexdigest is None:
  69. hexdigest = part.strip()
  70. continue
  71. try:
  72. (var_name, var_value) = list(x.strip() for x in part.split('='))
  73. if var_name == 'algo':
  74. if not var_value in supported_algos:
  75. algo = False
  76. raise KeyError('Algorithm "{}" not supported, try sha512 instead.'.format(var_value))
  77. algo = var_value
  78. except ValueError as e:
  79. log.info('Malformed X-Body-Hash part could not be parsed: {}'.format(part))
  80. except KeyError as e:
  81. log.info(e)
  82. if algo or algo is None:
  83. self.expecthash = (algo, hexdigest)
  84. log.debug('Expected hash value presented: '.self.expecthash)
  85. if os.path.exists(os.path.join(self._sha512dir, self.expecthash[1].lower())):
  86. self.sha512sum = self.expect_hash
  87. ''' this runs finish() '''
  88. self.sha512_existed()
  89. log.debug('Preparing to receive file data')
  90. self._uuid = uuid.uuid4().hex
  91. log.debug('Using uuid={}'.format(self._uuid))
  92. self._metadatadir = os.path.join('metadata', self._uuid)
  93. self._uploaddir = os.path.join('receiving', self._uuid)
  94. os.makedirs(self._metadatadir)
  95. os.makedirs(self._uploaddir)
  96. self._recvpath = os.path.join(self._uploaddir, 'receiving')
  97. def data_received(self, data):
  98. if self.recv_bytes == 0:
  99. self._file = open(self._recvpath, 'wb')
  100. log.debug('receiving %d bytes' % len(data))
  101. self.recv_bytes += len(data)
  102. self.sha512.update(data)
  103. self._file.write(data)
  104. log.debug('%d bytes received in total' % self.recv_bytes)
  105. def load_uuid_by_hash(self, hash_algo, hexdigest):
  106. with open(os.path.join('uploads', 'by-hash', hash_algo, hexdigest + '.uuid'), 'r') as f:
  107. uuid = f.read(127).strip()
  108. log.debug('read uuid=%s' % uuid)
  109. self._uuid = uuid
  110. return uuid
  111. def put(self, filename):
  112. if len(filename) > 127:
  113. (filename, ext) = os.path.splitext(filename)
  114. extlen = len(ext)+1 # with a dot
  115. filename = '%s.%s' % (filename[:(127-extlen)], ext,)
  116. self._file.close()
  117. self.sha512sum = self.sha512.hexdigest()
  118. if self.expect_hash and self.expect_hash != ('sha512', self.sha512sum,):
  119. raise ValueError('Actual hash did not match client X-Body-Hash header')
  120. sha512path = os.path.join(self._sha512dir, self.sha512sum)
  121. self._filename = os.path.basename(filename)
  122. if os.path.exists(sha512path):
  123. try:
  124. os.remove(self._recvpath)
  125. except FileNotFoundError as e:
  126. log.info('File already gone or never existed: {}'.format(self._recvpath))
  127. ''' FIXME: remove metadatadir with old uuid too '''
  128. self.sha512_existed()
  129. os.rename(self._recvpath, sha512path)
  130. xattr.setxattr(sha512path, 'user.filename', filename.encode())
  131. mimetype = Popen(['file', '-b','--mime-type', sha512path], stdout=PIPE).communicate()[0].decode('utf8').strip()
  132. xattr.setxattr(sha512path, 'user.mime_type', mimetype.encode())
  133. with open(os.path.join(self._metadatadir, 'filenames'), 'a') as f:
  134. f.write(filename + "\n")
  135. with open(os.path.join(sha512path + '.uuid'), 'w') as f:
  136. f.write(self._uuid)
  137. with open(os.path.join(self._metadatadir, 'mimetype'), 'w') as f:
  138. f.write(mimetype)
  139. with open(os.path.join(self._metadatadir, 'SHA512SUMS'), 'w') as f:
  140. f.write('{} file/{}'.format(self.sha512sum, self._filename))
  141. return self.report_back()
  142. def report_back(self, url=None, redirect=False):
  143. self.write(url or 'http://{}/uploads/by-hash/sha512/{}\n'.format(self.request.host, self.sha512sum))
  144. if redirect:
  145. self.redirect('http://{}/metadata/by-hash/sha512/{}'.format(self.request.host, self.sha512sum), status=303)
  146. supported_algos = ['sha512']
  147. templates = {
  148. 'ffmpeg__opus': {
  149. 'streams': ['audio'],
  150. 'command': 'ffmpeg',
  151. 'file_ext': 'opus',
  152. 'args': [
  153. ('-loglevel', 'warning',),
  154. ('-i', '%%FILENAME%%',),
  155. ('-f', 'opus',),
  156. ('-c:a', 'opus',),
  157. ('-vn'),
  158. ],
  159. },
  160. 'ffmpeg__flac': {
  161. 'streams': ['audio'],
  162. 'command': 'ffmpeg',
  163. 'file_ext': 'flac',
  164. 'args': [
  165. ('-loglevel', 'warning',),
  166. ('-f', 'flac',),
  167. ('-c:a', 'libvorbis',),
  168. ('-vn'),
  169. ],
  170. },
  171. 'ffmpeg__ogg_vorbis': {
  172. 'streams': ['audio'],
  173. 'command': 'ffmpeg',
  174. 'file_ext': 'ogg',
  175. 'args': [
  176. ('-loglevel', 'warning',),
  177. ('-i', '%%FILENAME%%',),
  178. ('-f', 'oga',),
  179. ('-c:a', 'libvorbis',),
  180. ('-vn'),
  181. ],
  182. },
  183. 'ffmpeg__wvga_webm_vorbis@128k_vp8@600k': {
  184. 'streams': ['video', 'audio'],
  185. 'command': 'ffmpeg',
  186. 'file_ext': 'webm',
  187. 'args': [
  188. ('-loglevel', 'warning',),
  189. ('-progress', '%%FFMPEG_PROGRESS_URL%%',),
  190. ('-i', '%%FILENAME%%',),
  191. ('-s', 'wvga',),
  192. ('-f', 'webm',),
  193. ('-c:a', 'libvorbis',),
  194. ('-b:a', '128k',),
  195. ('-c:v', 'libvpx',),
  196. ('-b:v', '600k',),
  197. ],
  198. },
  199. }
  200. class ProcessHandler(KonvertHandler):
  201. def load_filename(self, uuid):
  202. with open(os.path.join('uploads', 'by-uuid', uuid, 'filename'), 'r') as f:
  203. filename = f.read(127)
  204. log.debug('read filename=%s' % filename)
  205. return filename
  206. def load_mimetype(self, uuid):
  207. with open(os.path.join('uploads', 'by-uuid', uuid, 'mimetype'), 'r') as f:
  208. mimetype = f.read(127)
  209. log.debug('read mimetype=%s' % mimetype)
  210. return mimetype
  211. def load_template(self, template_name):
  212. return templates[template_name]
  213. def get(self, uuid, template_name):
  214. filename = self.load_filename(uuid)
  215. metadata_dir = os.path.join('uploads', 'by-uuid', uuid)
  216. filepath = os.path.join(metadata_dir, 'file', filename)
  217. mimetype = self.load_mimetype(uuid)
  218. self.writeln('%s from %s to %s' % (uuid, mimetype, template_name,))
  219. template = self.load_template(template_name)
  220. outname = '%s.%s' % (os.path.splitext(filename)[0], template['file_ext'],)
  221. outdir = os.path.join('uploads', 'by-uuid', uuid, 'convert', template_name)
  222. os.makedirs(outdir)
  223. outtmp = os.path.join(outdir, 'converting')
  224. outpath = os.path.join(outdir, outname)
  225. progress_url = 'http://{}/uploads/{}/convert/{}/progress'.format(self.request.host, uuid, url_escape(template_name, plus=False))
  226. ffmpeg_cmd = [template['command']]
  227. for arg in template['args']:
  228. for part in arg:
  229. log.debug('replacing macros with values in: %s' % part)
  230. part = part.replace('%%FILENAME%%', filepath)
  231. part = part.replace('%%FFMPEG_PROGRESS_URL%%', progress_url)
  232. ffmpeg_cmd.append(part)
  233. ffmpeg_cmd += [outtmp]
  234. ffmpeg_log = open(os.path.join(outdir, 'ffmpeg.log'), 'wb')
  235. log.debug('Running %s' % ffmpeg_cmd)
  236. p_conv = Popen(ffmpeg_cmd, stdout=ffmpeg_log, stderr=subprocess.STDOUT)
  237. try:
  238. p_conv.wait(2)
  239. except subprocess.TimeoutExpired as e:
  240. self.writeln('Resulting file will be available at %s' % 'http://{}/uploads/{}/convert/{}/{}'.format(self.request.host, uuid, url_escape(template_name, plus=False), url_escape(filename, plus=False)))
  241. self.set_status(202, reason='Accepted, will konvert it. Please come back later.')
  242. return
  243. if p_conv.poll() is None:
  244. raise IOError('Process returncode None despite that it should have ended!')
  245. if p_conv.returncode < 0:
  246. raise ValueError('ffmpeg exited unnaturally with POSIX signal %d' % abs(p_conv.returncode))
  247. elif p_conv.returncode > 0:
  248. self.set_status(500, 'ffmpeg exited due to an error, please see the log output at {}'.format('http://{}/uploads/{}/convert/{}/ffmpeg.log'.format(self.request.host, uuid, template_name), status=500))
  249. return
  250. return self.report_back(uuid, template_name, outname)
  251. def report_back(self, uuid, template_name, filename):
  252. url = 'http://{}/uploads/{}/convert/{}'.format(self.request.host, uuid, url_escape(filename, plus=False))
  253. self.writeln(url)
  254. self.redirect(url)
  255. @tornado.web.stream_request_body
  256. class ProgressHandler(tornado.web.RequestHandler):
  257. def load_progress(self, uuid, template_name):
  258. template_name = os.path.basename(template_name)
  259. with open(os.path.join('uploads', 'by-uuid', uuid, 'convert', template_name, 'progress'), 'r') as f:
  260. progress = f.read(127)
  261. log.debug('read progress=%s' % progress)
  262. return progress
  263. def prepare(self):
  264. log.debug('new progress for %s template %s' % (self.path_kwargs['uuid'], self.path_kwargs['template_name']))
  265. self._uuid = self.path_kwargs['uuid']
  266. self._template_name = self.path_kwargs['template_name']
  267. def data_received(self, data):
  268. #self.save_progress(data)
  269. log.debug('progress ongoing: %s' % data)
  270. def get(self, uuid, template_name):
  271. self.write(self.load_progress(uuid, template_name))
  272. def post(self, uuid, template_name):
  273. log.debug('%s with %s' % (uuid, template_name,))
  274. class HelpHandler(KonvertHandler):
  275. def get(self, section):
  276. self.writeln('<h1>Konvert Help</h1>')
  277. if section == 'convert':
  278. self.writeln('<h2>Convert</h2>')
  279. self.writeln('<p>Convert by appending the UUID URL with <code>/convert/%%TEMPLATE_NAME%%</code> where <code>%%TEMPLATE_NAME%%</code> is one of the templates listed below.</p>')
  280. self.writeln('<h3>Templates</h3>')
  281. self.writeln('<ul>')
  282. for template in templates:
  283. self.write('<li>{}</li>'.format(htmlspecialchars(template)))
  284. self.writeln('</ul>')
  285. application = tornado.web.Application([
  286. (r'/uploads/(by-hash/sha512/[a-f0-9]{16,})', tornado.web.StaticFileHandler, {
  287. 'path': os.path.join(os.path.dirname(__file__), 'uploads'),
  288. }),
  289. (r'/uploads/([a-f0-9]{16,}/file/.*)', tornado.web.StaticFileHandler, {
  290. 'path': os.path.join(os.path.dirname(__file__), 'uploads'),
  291. }),
  292. (r'/uploads/([a-f0-9]{16,}/convert/[\w\d\-\_\.\@\%]+/(?!progress).*)', tornado.web.StaticFileHandler, {
  293. 'path': os.path.join(os.path.dirname(__file__), 'uploads'),
  294. }),
  295. (r'/uploads/(?P<uuid>[a-f0-9]{16,})/convert/(?P<template_name>[\w\d\-\_\.\@\%]+)/progress', ProgressHandler),
  296. (r'/uploads/(?P<uuid>[a-f0-9]{16,})/convert/(?P<template_name>[\w\d\-\_\.\@\%]+)', ProcessHandler),
  297. (r'/help/(\w+)', HelpHandler),
  298. (r'/index.html', IndexHandler, {'path': os.path.join(os.path.dirname(__file__), 'uploads')}),
  299. (r'/(.*)', MainHandler),
  300. ], autoreload=True, debug=True)
  301. if __name__ == '__main__':
  302. tornado.options.parse_command_line()
  303. path = os.path.join(os.path.join(os.path.dirname(__file__)), 'uploads')
  304. if not os.path.exists(path):
  305. os.makedirs(path)
  306. port = 8888
  307. log.info('Listening on %d' % port)
  308. application.listen(port)
  309. tornado.ioloop.IOLoop.instance().start()