toot-my-t-shirt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """toot-my-t-shirt
  4. Copyright 2018-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 json
  17. import base64
  18. import logging
  19. import tempfile
  20. import datetime
  21. import tornado.httpserver
  22. import tornado.ioloop
  23. from tornado.options import define, options
  24. import tornado.web
  25. import tornado.websocket
  26. from tornado import gen, escape
  27. from mastodon import Mastodon
  28. API_VERSION = '1.0'
  29. # Keep track of WebSocket connections.
  30. _ws_clients = {}
  31. re_slashes = re.compile(r'//+')
  32. class Socialite:
  33. def __init__(self, options, logger=None):
  34. self.options = options
  35. self.logger = logger
  36. self.init()
  37. with_mastodon = property(lambda self: self.options.mastodon_token and
  38. self.options.mastodon_api_url)
  39. with_store = property(lambda self: bool(self.options.store_dir))
  40. def init(self):
  41. self.mastodon = None
  42. if self.with_store:
  43. if not os.path.isdir(self.options.store_dir):
  44. os.makedirs(self.options.store_dir)
  45. if self.with_mastodon:
  46. self.mastodon = Mastodon(access_token=self.options.mastodon_token,
  47. api_base_url=self.options.mastodon_api_url)
  48. def post_image(self, img, mime_type='image/jpeg', message=None, description=None):
  49. errors = []
  50. if message is None:
  51. message = self.options.default_message
  52. if description is None:
  53. description = self.options.default_image_description
  54. if self.with_store:
  55. try:
  56. self.store_image(img, mime_type, message, description)
  57. except Exception as e:
  58. errors.append(str(e))
  59. if self.with_mastodon:
  60. try:
  61. self.mastodon_post_image(img, mime_type, message, description)
  62. except Exception as e:
  63. errors.append(str(e))
  64. if errors and self.logger:
  65. for err in errors:
  66. self.logger.error("ERROR: %s" % err)
  67. return errors
  68. def mastodon_post_image(self, img, mime_type, message, description):
  69. mdict = self.mastodon.media_post(media_file=img, mime_type=mime_type, description=description)
  70. media_id = mdict['id']
  71. self.mastodon.status_post(status=message, media_ids=[media_id],
  72. visibility=self.options.mastodon_visibility)
  73. def store_image(self, img, mime_type, message, description):
  74. suffix = '.jpg'
  75. if mime_type:
  76. ms = mime_type.split('/', 1)
  77. if len(ms) == 2 and ms[1]:
  78. suffix = '.' + ms[1]
  79. prefix = str(datetime.datetime.now()).replace(' ', 'T') + '-'
  80. fd, fname = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=self.options.store_dir)
  81. os.write(fd, img)
  82. os.close(fd)
  83. txt_fname = '%s.info' % fname
  84. with open(txt_fname, 'w') as tfd:
  85. tfd.write('message: %s\n' % message or '')
  86. tfd.write('description: %s\n' % description or '')
  87. class BaseException(Exception):
  88. """Base class for toot-my-t-shirt custom exceptions.
  89. :param message: text message
  90. :type message: str
  91. :param status: numeric http status code
  92. :type status: int"""
  93. def __init__(self, message, status=200):
  94. super(BaseException, self).__init__(message)
  95. self.message = message
  96. self.status = status
  97. class InputException(BaseException):
  98. """Exception raised by errors in input handling."""
  99. pass
  100. class BaseHandler(tornado.web.RequestHandler):
  101. """Base class for request handlers."""
  102. # A property to access the first value of each argument.
  103. arguments = property(lambda self: dict([(k, v[0].decode('utf-8'))
  104. for k, v in self.request.arguments.items()]))
  105. @property
  106. def json_body(self):
  107. """Return a dictionary from a JSON body.
  108. :returns: a copy of the body arguments
  109. :rtype: dict"""
  110. return escape.json_decode(self.request.body or '{}')
  111. def write_error(self, status_code, **kwargs):
  112. """Default error handler."""
  113. if isinstance(kwargs.get('exc_info', (None, None))[1], BaseException):
  114. exc = kwargs['exc_info'][1]
  115. status_code = exc.status
  116. message = exc.message
  117. else:
  118. message = 'internal error'
  119. self.build_error(message, status=status_code)
  120. def is_api(self):
  121. """Return True if the path is from an API call."""
  122. return self.request.path.startswith('/v%s' % API_VERSION)
  123. def initialize(self, **kwargs):
  124. """Add every passed (key, value) as attributes of the instance."""
  125. for key, value in kwargs.items():
  126. setattr(self, key, value)
  127. def build_error(self, message='', status=200):
  128. """Build and write an error message.
  129. :param message: textual message
  130. :type message: str
  131. :param status: HTTP status code
  132. :type status: int
  133. """
  134. self.set_status(status)
  135. self.write({'success': False, 'message': message})
  136. class RootHandler(BaseHandler):
  137. """Handler for the / path."""
  138. app_path = os.path.join(os.path.dirname(__file__), "static")
  139. @gen.coroutine
  140. def get(self, *args, **kwargs):
  141. # serve the ./static/index.html file
  142. with open(self.app_path + "/index.html", 'r') as fd:
  143. self.write(fd.read())
  144. class PublishHandler(BaseHandler):
  145. @gen.coroutine
  146. def post(self, **kwargs):
  147. reply = {'success': True}
  148. for info in self.request.files['selfie']:
  149. _, content_type = info['filename'], info['content_type']
  150. body = info['body']
  151. b64_image = body.split(b',')[1]
  152. image = base64.decodestring(b64_image)
  153. try:
  154. errors = self.socialite.post_image(image)
  155. if errors:
  156. reply['success'] = False
  157. reply['message'] = '<br>\n'.join(errors)
  158. except Exception as e:
  159. reply = {'success': False, 'message': 'something wrong sharing the image'}
  160. self.write(reply)
  161. class ButtonHandler(BaseHandler):
  162. @gen.coroutine
  163. def post(self, **kwargs):
  164. reply = {'success': True}
  165. self.send_ws_message('/ws', json.dumps({"source": "button", "action": "clicked"}))
  166. self.write(reply)
  167. @gen.coroutine
  168. def send_ws_message(self, path, message):
  169. """Send a WebSocket message to all the connected clients.
  170. :param path: partial path used to build the WebSocket url
  171. :type path: str
  172. :param message: message to send
  173. :type message: str
  174. """
  175. try:
  176. url = '%s://localhost:%s/ws?uuid=bigredbutton' % ('wss' if self.ssl_options else 'ws',
  177. self.global_options.port)
  178. self.logger.info(url)
  179. req = tornado.httpclient.HTTPRequest(url, validate_cert=False)
  180. ws = yield tornado.websocket.websocket_connect(req)
  181. ws.write_message(message)
  182. ws.close()
  183. except Exception as e:
  184. self.logger.error('Error yielding WebSocket message: %s', e)
  185. class WSHandler(tornado.websocket.WebSocketHandler):
  186. def initialize(self, **kwargs):
  187. """Add every passed (key, value) as attributes of the instance."""
  188. for key, value in kwargs.items():
  189. setattr(self, key, value)
  190. def _clean_url(self, url):
  191. url = re_slashes.sub('/', url)
  192. ridx = url.rfind('?')
  193. if ridx != -1:
  194. url = url[:ridx]
  195. return url
  196. def open(self, *args, **kwargs):
  197. try:
  198. self.uuid = self.get_argument('uuid')
  199. except:
  200. self.uuid = None
  201. url = self._clean_url(self.request.uri)
  202. _ws_clients.setdefault(url, {})
  203. if self.uuid and self.uuid not in _ws_clients[url]:
  204. _ws_clients[url][self.uuid] = self
  205. self.logger.debug('WSHandler.open %s clients connected' % len(_ws_clients[url]))
  206. def on_message(self, message):
  207. url = self._clean_url(self.request.uri)
  208. self.logger.debug('WSHandler.on_message url: %s' % url)
  209. count = 0
  210. _to_delete = set()
  211. current_uuid = None
  212. try:
  213. current_uuid = self.get_argument('uuid')
  214. except:
  215. pass
  216. for uuid, client in _ws_clients.get(url, {}).items():
  217. if uuid and uuid == current_uuid:
  218. continue
  219. try:
  220. client.write_message(message)
  221. except:
  222. _to_delete.add(uuid)
  223. continue
  224. count += 1
  225. for uuid in _to_delete:
  226. try:
  227. del _ws_clients[url][uuid]
  228. except KeyError:
  229. pass
  230. self.logger.debug('WSHandler.on_message sent message to %d clients' % count)
  231. def run():
  232. """Run the Tornado web application."""
  233. # command line arguments; can also be written in a configuration file,
  234. # specified with the --config argument.
  235. define("port", default=9000, help="listen on the given port", type=int)
  236. define("address", default='', help="bind the server at the given address", type=str)
  237. define("default-message", help="Default message", type=str)
  238. define("default-image-description", help="Default image description", type=str)
  239. define("mastodon-token", help="Mastodon token", type=str)
  240. define("mastodon-api-url", help="Mastodon API URL", type=str)
  241. define("mastodon-visibility", help="Mastodon toot visibility", default='unlisted')
  242. define("store-dir", help="store images in this directory", type=str)
  243. define("ssl_cert", default=os.path.join(os.path.dirname(__file__), 'ssl', 'sb-cert.pem'),
  244. help="specify the SSL certificate to use for secure connections")
  245. define("ssl_key", default=os.path.join(os.path.dirname(__file__), 'ssl', 'sb-cert.key'),
  246. help="specify the SSL private key to use for secure connections")
  247. define("debug", default=False, help="run in debug mode")
  248. define("config", help="read configuration file",
  249. callback=lambda path: tornado.options.parse_config_file(path, final=False))
  250. tornado.options.parse_command_line()
  251. logger = logging.getLogger()
  252. logger.setLevel(logging.INFO)
  253. if options.debug:
  254. logger.setLevel(logging.DEBUG)
  255. ssl_options = {}
  256. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  257. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  258. socialite = Socialite(options, logger=logger)
  259. init_params = dict(global_options=options, ssl_options=ssl_options, socialite=socialite, logger=logger)
  260. _publish_path = r"/publish/?"
  261. _button_path = r"/button/?"
  262. application = tornado.web.Application([
  263. (r'/ws', WSHandler, init_params),
  264. (_publish_path, PublishHandler, init_params),
  265. (r'/v%s%s' % (API_VERSION, _publish_path), PublishHandler, init_params),
  266. (_button_path, ButtonHandler, init_params),
  267. (r'/v%s%s' % (API_VERSION, _button_path), ButtonHandler, init_params),
  268. (r"/(?:index.html)?", RootHandler, init_params),
  269. (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "static"})
  270. ],
  271. static_path=os.path.join(os.path.dirname(__file__), "static"),
  272. debug=options.debug)
  273. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  274. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  275. options.address if options.address else '127.0.0.1',
  276. options.port)
  277. http_server.listen(options.port, options.address)
  278. tornado.ioloop.IOLoop.instance().start()
  279. if __name__ == '__main__':
  280. try:
  281. run()
  282. except KeyboardInterrupt:
  283. print('Server stopped')