httprint.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """httprint - print files via web
  4. Copyright 2019 Davide Alberani <da@mimante.net>
  5. Licensed under the Apache License, Version 2.0 (the "License");
  6. you may not use this file except in compliance with the License.
  7. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. """
  14. import os
  15. import re
  16. import time
  17. import glob
  18. import random
  19. import shutil
  20. import logging
  21. import subprocess
  22. import multiprocessing as mp
  23. from tornado.ioloop import IOLoop
  24. import tornado.httpserver
  25. import tornado.options
  26. from tornado.options import define, options
  27. import tornado.web
  28. from tornado import gen, escape
  29. API_VERSION = '1.0'
  30. QUEUE_DIR = 'queue'
  31. ARCHIVE = True
  32. ARCHIVE_DIR = 'archive'
  33. PRINT_CMD = 'lp -n %(copies)s'
  34. CODE_DIGITS = 4
  35. MAX_PAGES = 10
  36. PRINT_WITH_CODE = True
  37. logger = logging.getLogger()
  38. logger.setLevel(logging.INFO)
  39. re_pages = re.compile('^Pages:\s+(\d+)$', re.M | re.I)
  40. class HTTPrintBaseException(Exception):
  41. """Base class for httprint custom exceptions.
  42. :param message: text message
  43. :type message: str
  44. :param status: numeric http status code
  45. :type status: int"""
  46. def __init__(self, message, status=400):
  47. super(HTTPrintBaseException, self).__init__(message)
  48. self.message = message
  49. self.status = status
  50. class BaseHandler(tornado.web.RequestHandler):
  51. """Base class for request handlers."""
  52. # A property to access the first value of each argument.
  53. arguments = property(lambda self: dict([(k, v[0].decode('utf-8'))
  54. for k, v in self.request.arguments.items()]))
  55. @property
  56. def clean_body(self):
  57. """Return a clean dictionary from a JSON body, suitable for a query on MongoDB.
  58. :returns: a clean copy of the body arguments
  59. :rtype: dict"""
  60. return escape.json_decode(self.request.body or '{}')
  61. def write_error(self, status_code, **kwargs):
  62. """Default error handler."""
  63. if isinstance(kwargs.get('exc_info', (None, None))[1], HTTPrintBaseException):
  64. exc = kwargs['exc_info'][1]
  65. status_code = exc.status
  66. message = exc.message
  67. else:
  68. message = 'internal error'
  69. self.build_error(message, status=status_code)
  70. def initialize(self, **kwargs):
  71. """Add every passed (key, value) as attributes of the instance."""
  72. for key, value in kwargs.items():
  73. setattr(self, key, value)
  74. def build_error(self, message='', status=400):
  75. """Build and write an error message.
  76. :param message: textual message
  77. :type message: str
  78. :param status: HTTP status code
  79. :type status: int
  80. """
  81. self.set_status(status)
  82. self.write({'error': True, 'message': message})
  83. def build_success(self, message='', status=200):
  84. """Build and write a success message.
  85. :param message: textual message
  86. :type message: str
  87. :param status: HTTP status code
  88. :type status: int
  89. """
  90. self.set_status(status)
  91. self.write({'error': False, 'message': message})
  92. def _run(self, cmd, fname, callback=None):
  93. p = subprocess.Popen(cmd, close_fds=True)
  94. p.communicate()
  95. if callback:
  96. callback(cmd, fname, p)
  97. def _archive(self, cmd, fname, p):
  98. if os.path.isfile('%s.keep' % fname):
  99. return
  100. if self.cfg.archive:
  101. if not os.path.isdir(self.cfg.archive_dir):
  102. os.makedirs(self.cfg.archive_dir)
  103. for fn in glob.glob(fname + '*'):
  104. shutil.move(fn, self.cfg.archive_dir)
  105. for fn in glob.glob(fname + '*'):
  106. try:
  107. os.unlink(fn)
  108. except Exception:
  109. pass
  110. def run_subprocess(self, cmd, fname, callback=None):
  111. """Execute the given action.
  112. :param cmd: the command to be run with its command line arguments
  113. :type cmd: list
  114. """
  115. p = mp.Process(target=self._run, args=(cmd, fname, callback))
  116. p.start()
  117. def print_file(self, fname):
  118. copies = 1
  119. try:
  120. with open(fname + '.copies', 'r') as fd:
  121. copies = int(fd.read())
  122. if copies < 1:
  123. copies = 1
  124. except Exception:
  125. pass
  126. print_cmd = self.cfg.print_cmd.split(' ')
  127. cmd = [x % {'copies': copies} for x in print_cmd] + [fname]
  128. self.run_subprocess(cmd, fname, self._archive)
  129. class PrintHandler(BaseHandler):
  130. """File print handler."""
  131. @gen.coroutine
  132. def post(self, code=None):
  133. if not code:
  134. self.build_error("empty code")
  135. return
  136. files = [x for x in sorted(glob.glob(self.cfg.queue_dir + '/%s-*' % code))
  137. if not x.endswith('.info') and not x.endswith('.pages')]
  138. if not files:
  139. self.build_error("no matching files")
  140. return
  141. self.print_file(files[0])
  142. self.build_success("file sent to printer")
  143. class UploadHandler(BaseHandler):
  144. """File upload handler."""
  145. def generateCode(self):
  146. filler = '%0' + str(self.cfg.code_digits) + 'd'
  147. existing = set()
  148. re_code = re.compile('(\d{' + str(self.cfg.code_digits) + '})-.*')
  149. for fname in glob.glob(self.cfg.queue_dir + '/*'):
  150. fname = os.path.basename(fname)
  151. match = re_code.match(fname)
  152. if not match:
  153. continue
  154. fcode = match.group(1)
  155. existing.add(fcode)
  156. code = None
  157. for i in range(10**self.cfg.code_digits):
  158. intCode = random.randint(0, (10**self.cfg.code_digits)-1)
  159. code = filler % intCode
  160. if code not in existing:
  161. break
  162. return code
  163. @gen.coroutine
  164. def post(self):
  165. if not self.request.files.get('file'):
  166. self.build_error("no file uploaded")
  167. return
  168. copies = 1
  169. try:
  170. copies = int(self.get_argument('copies'))
  171. if copies < 1:
  172. copies = 1
  173. except Exception:
  174. pass
  175. if copies > self.cfg.max_pages:
  176. self.build_error('you have asked too many copies')
  177. return
  178. fileinfo = self.request.files['file'][0]
  179. webFname = fileinfo['filename']
  180. extension = ''
  181. try:
  182. extension = os.path.splitext(webFname)[1]
  183. except Exception:
  184. pass
  185. if not os.path.isdir(self.cfg.queue_dir):
  186. os.makedirs(self.cfg.queue_dir)
  187. now = time.strftime('%Y%m%d%H%M%S')
  188. code = self.generateCode()
  189. fname = '%s-%s%s' % (code, now, extension)
  190. pname = os.path.join(self.cfg.queue_dir, fname)
  191. try:
  192. with open(pname, 'wb') as fd:
  193. fd.write(fileinfo['body'])
  194. except Exception as e:
  195. self.build_error("error writing file %s: %s" % (pname, e))
  196. return
  197. try:
  198. with open(pname + '.info', 'w') as fd:
  199. fd.write('original file name: %s\n' % webFname)
  200. fd.write('uploaded on: %s\n' % now)
  201. fd.write('copies: %d\n' % copies)
  202. except Exception:
  203. pass
  204. try:
  205. with open(pname + '.copies', 'w') as fd:
  206. fd.write('%d' % copies)
  207. except Exception:
  208. pass
  209. failure = False
  210. if self.cfg.check_pdf_pages or self.cfg.pdf_only:
  211. try:
  212. p = subprocess.Popen(['pdfinfo', pname], stdout=subprocess.PIPE)
  213. out, _ = p.communicate()
  214. if p.returncode != 0 and self.cfg.pdf_only:
  215. self.build_error('the uploaded file does not seem to be a PDF')
  216. failure = True
  217. out = out.decode('utf-8', errors='ignore')
  218. pages = int(re_pages.findall(out)[0])
  219. if pages * copies > self.cfg.max_pages and self.cfg.check_pdf_pages and not failure:
  220. self.build_error('too many pages to print (%d)' % (pages * copies))
  221. failure = True
  222. except Exception:
  223. if not failure:
  224. self.build_error('unable to get PDF information')
  225. failure = True
  226. pass
  227. if failure:
  228. for fn in glob.glob(pname + '*'):
  229. try:
  230. os.unlink(fn)
  231. except Exception:
  232. pass
  233. return
  234. if self.cfg.print_with_code:
  235. self.build_success("go to the printer and enter this code: %s" % code)
  236. else:
  237. self.print_file(pname)
  238. self.build_success("file sent to printer")
  239. class TemplateHandler(BaseHandler):
  240. """Handler for the template files in the / path."""
  241. @gen.coroutine
  242. def get(self, *args, **kwargs):
  243. """Get a template file."""
  244. page = 'index.html'
  245. if args and args[0]:
  246. page = args[0].strip('/')
  247. arguments = self.arguments
  248. self.render(page, **arguments)
  249. def serve():
  250. """Read configuration and start the server."""
  251. define('port', default=7777, help='run on the given port', type=int)
  252. define('address', default='', help='bind the server at the given address', type=str)
  253. define('ssl_cert', default=os.path.join(os.path.dirname(__file__), 'ssl', 'httprint_cert.pem'),
  254. help='specify the SSL certificate to use for secure connections')
  255. define('ssl_key', default=os.path.join(os.path.dirname(__file__), 'ssl', 'httprint_key.pem'),
  256. help='specify the SSL private key to use for secure connections')
  257. define('code-digits', default=CODE_DIGITS, help='number of digits of the code', type=int)
  258. define('max-pages', default=MAX_PAGES, help='maximum number of pages to print', type=int)
  259. define('queue-dir', default=QUEUE_DIR, help='directory to store files before they are printed', type=str)
  260. define('archive', default=True, help='archive printed files', type=bool)
  261. define('archive-dir', default=ARCHIVE_DIR, help='directory to archive printed files', type=str)
  262. define('print-with-code', default=True, help='a code must be entered for printing', type=bool)
  263. define('pdf-only', default=True, help='only print PDF files', type=bool)
  264. define('check-pdf-pages', default=True, help='check that the number of pages of PDF files do not exeed --max-pages', type=bool)
  265. define('print-cmd', default=PRINT_CMD, help='command used to print the documents')
  266. define('debug', default=False, help='run in debug mode', type=bool)
  267. tornado.options.parse_command_line()
  268. if options.debug:
  269. logger.setLevel(logging.DEBUG)
  270. ssl_options = {}
  271. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  272. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  273. init_params = dict(listen_port=options.port, logger=logger, ssl_options=ssl_options, cfg=options)
  274. _upload_path = r'upload/?'
  275. _print_path = r'print/(?P<code>\d+)'
  276. application = tornado.web.Application([
  277. (r'/api/%s' % _upload_path, UploadHandler, init_params),
  278. (r'/api/v%s/%s' % (API_VERSION, _upload_path), UploadHandler, init_params),
  279. (r'/api/%s' % _print_path, PrintHandler, init_params),
  280. (r'/api/v%s/%s' % (API_VERSION, _print_path), PrintHandler, init_params),
  281. (r'/?(.*)', TemplateHandler, init_params),
  282. ],
  283. static_path=os.path.join(os.path.dirname(__file__), 'dist/static'),
  284. template_path=os.path.join(os.path.dirname(__file__), 'dist/'),
  285. debug=options.debug)
  286. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  287. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  288. options.address if options.address else '127.0.0.1',
  289. options.port)
  290. http_server.listen(options.port, options.address)
  291. try:
  292. IOLoop.instance().start()
  293. except (KeyboardInterrupt, SystemExit):
  294. pass
  295. if __name__ == '__main__':
  296. serve()