toot-my-t-shirt 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """toot-my-t-shirt
  4. Copyright 2018 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 base64
  16. import logging
  17. import tempfile
  18. import datetime
  19. import tornado.httpserver
  20. import tornado.ioloop
  21. from tornado.options import define, options
  22. import tornado.web
  23. from tornado import gen, escape
  24. from mastodon import Mastodon
  25. API_VERSION = '1.0'
  26. class Socialite:
  27. def __init__(self, options):
  28. self.options = options
  29. self.init()
  30. with_mastodon = property(lambda self: self.options.mastodon_token and
  31. self.options.mastodon_api_url)
  32. with_store = property(lambda self: bool(self.options.store_dir))
  33. def init(self):
  34. self.mastodon = None
  35. if self.with_store:
  36. if not os.path.isdir(self.options.store_dir):
  37. os.makedirs(self.options.store_dir)
  38. if self.with_mastodon:
  39. self.mastodon = Mastodon(access_token=self.options.mastodon_token,
  40. api_base_url=self.options.mastodon_api_url)
  41. def post_image(self, img, mime_type='image/jpeg', message=None, description=None):
  42. if message is None:
  43. message = self.options.default_message
  44. if description is None:
  45. description = self.options.default_image_description
  46. if self.with_store:
  47. self.store_image(img, mime_type, message, description)
  48. if self.with_mastodon:
  49. self.mastodon_post_image(img, mime_type, message, description)
  50. def mastodon_post_image(self, img, mime_type, message, description):
  51. mdict = self.mastodon.media_post(media_file=img, mime_type=mime_type, description=description)
  52. media_id = mdict['id']
  53. self.mastodon.status_post(status=message, media_ids=[media_id])
  54. def store_image(self, img, mime_type, message, description):
  55. suffix = '.jpg'
  56. if mime_type:
  57. ms = mime_type.split('/', 1)
  58. if len(ms) == 2 and ms[1]:
  59. suffix = '.' + ms[1]
  60. prefix = str(datetime.datetime.now()).replace(' ', 'T') + '-'
  61. fd, fname = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=self.options.store_dir)
  62. print(fd, fname)
  63. os.write(fd, img)
  64. os.close(fd)
  65. txt_fname = '%s.info' % fname
  66. with open(txt_fname, 'w') as tfd:
  67. tfd.write('message: %s\n' % message or '')
  68. tfd.write('description: %s\n' % description or '')
  69. class BaseException(Exception):
  70. """Base class for toot-my-t-shirt custom exceptions.
  71. :param message: text message
  72. :type message: str
  73. :param status: numeric http status code
  74. :type status: int"""
  75. def __init__(self, message, status=400):
  76. super(BaseException, self).__init__(message)
  77. self.message = message
  78. self.status = status
  79. class InputException(BaseException):
  80. """Exception raised by errors in input handling."""
  81. pass
  82. class BaseHandler(tornado.web.RequestHandler):
  83. """Base class for request handlers."""
  84. # A property to access the first value of each argument.
  85. arguments = property(lambda self: dict([(k, v[0].decode('utf-8'))
  86. for k, v in self.request.arguments.items()]))
  87. @property
  88. def json_body(self):
  89. """Return a dictionary from a JSON body.
  90. :returns: a copy of the body arguments
  91. :rtype: dict"""
  92. return escape.json_decode(self.request.body or '{}')
  93. def write_error(self, status_code, **kwargs):
  94. """Default error handler."""
  95. if isinstance(kwargs.get('exc_info', (None, None))[1], BaseException):
  96. exc = kwargs['exc_info'][1]
  97. status_code = exc.status
  98. message = exc.message
  99. else:
  100. message = 'internal error'
  101. self.build_error(message, status=status_code)
  102. def is_api(self):
  103. """Return True if the path is from an API call."""
  104. return self.request.path.startswith('/v%s' % API_VERSION)
  105. def initialize(self, **kwargs):
  106. """Add every passed (key, value) as attributes of the instance."""
  107. for key, value in kwargs.items():
  108. setattr(self, key, value)
  109. def build_error(self, message='', status=400):
  110. """Build and write an error message.
  111. :param message: textual message
  112. :type message: str
  113. :param status: HTTP status code
  114. :type status: int
  115. """
  116. self.set_status(status)
  117. self.write({'error': True, 'message': message})
  118. class RootHandler(BaseHandler):
  119. """Handler for the / path."""
  120. app_path = os.path.join(os.path.dirname(__file__), "static")
  121. @gen.coroutine
  122. def get(self, *args, **kwargs):
  123. # serve the ./static/index.html file
  124. with open(self.app_path + "/index.html", 'r') as fd:
  125. self.write(fd.read())
  126. class PublishHandler(BaseHandler):
  127. @gen.coroutine
  128. def post(self, **kwargs):
  129. reply = {'success': True}
  130. for info in self.request.files['selfie']:
  131. _, content_type = info['filename'], info['content_type']
  132. body = info['body']
  133. b64_image = body.split(b',')[1]
  134. image = base64.decodestring(b64_image)
  135. with open('/tmp/selfie.jpeg', 'wb') as fd:
  136. fd.write(image)
  137. self.socialite.post_image(image)
  138. self.write(reply)
  139. def run():
  140. """Run the Tornado web application."""
  141. # command line arguments; can also be written in a configuration file,
  142. # specified with the --config argument.
  143. define("port", default=9000, help="listen on the given port", type=int)
  144. define("address", default='', help="bind the server at the given address", type=str)
  145. define("default-message", help="Default message", type=str)
  146. define("default-image-description", help="Default image description", type=str)
  147. define("mastodon-token", help="Mastodon token", type=str)
  148. define("mastodon-api-url", help="Mastodon API URL", type=str)
  149. define("store-dir", help="store images in this directory", type=str)
  150. define("ssl_cert", default=os.path.join(os.path.dirname(__file__), 'ssl', 'sb-cert.pem'),
  151. help="specify the SSL certificate to use for secure connections")
  152. define("ssl_key", default=os.path.join(os.path.dirname(__file__), 'ssl', 'sb-cert.key'),
  153. help="specify the SSL private key to use for secure connections")
  154. define("debug", default=False, help="run in debug mode")
  155. define("config", help="read configuration file",
  156. callback=lambda path: tornado.options.parse_config_file(path, final=False))
  157. tornado.options.parse_command_line()
  158. logger = logging.getLogger()
  159. logger.setLevel(logging.INFO)
  160. if options.debug:
  161. logger.setLevel(logging.DEBUG)
  162. ssl_options = {}
  163. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  164. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  165. socialite = Socialite(options)
  166. init_params = dict(global_options=options, socialite=socialite)
  167. _publish_path = r"/publish/?"
  168. application = tornado.web.Application([
  169. (_publish_path, PublishHandler, init_params),
  170. (r'/v%s%s' % (API_VERSION, _publish_path), PublishHandler, init_params),
  171. (r"/(?:index.html)?", RootHandler, init_params),
  172. (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "static"})
  173. ],
  174. static_path=os.path.join(os.path.dirname(__file__), "static"),
  175. debug=options.debug)
  176. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  177. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  178. options.address if options.address else '127.0.0.1',
  179. options.port)
  180. http_server.listen(options.port, options.address)
  181. tornado.ioloop.IOLoop.instance().start()
  182. if __name__ == '__main__':
  183. try:
  184. run()
  185. except KeyboardInterrupt:
  186. print('Server stopped')