Commit 6deb4a4fe31ff9b6b386f6524142926730265bd8
1 parent
ea4ff4d2
Exists in
master
and in
1 other branch
- file support added. Every file in public subdir of some topic is accessible th…
…rough /file/filename. In the question text images can be referred in markdown as `` - files are served asynchronously in chunks of 1MiB.
Showing
4 changed files
with
46 additions
and
36 deletions
Show diff stats
BUGS.md
| @@ -11,7 +11,6 @@ | @@ -11,7 +11,6 @@ | ||
| 11 | 11 | ||
| 12 | # TODO | 12 | # TODO |
| 13 | 13 | ||
| 14 | -- servir imagens/ficheiros. | ||
| 15 | - session management. close after inactive time. | 14 | - session management. close after inactive time. |
| 16 | - each topic only loads a sample of K questions (max) in random order. | 15 | - each topic only loads a sample of K questions (max) in random order. |
| 17 | - radio e checkboxes, aceitar numeros como seleccao das opcoes. | 16 | - radio e checkboxes, aceitar numeros como seleccao das opcoes. |
| @@ -30,6 +29,7 @@ | @@ -30,6 +29,7 @@ | ||
| 30 | 29 | ||
| 31 | # FIXED | 30 | # FIXED |
| 32 | 31 | ||
| 32 | +- servir imagens/ficheiros. | ||
| 33 | - radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa). | 33 | - radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa). |
| 34 | - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória. | 34 | - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória. |
| 35 | - async/threadpool no bcrypt do initdb. | 35 | - async/threadpool no bcrypt do initdb. |
demo/solar_system/questions.yaml
| 1 | --- | 1 | --- |
| 2 | 2 | ||
| 3 | -# # --------------------------------------------------------------------------- | ||
| 4 | -# - | ||
| 5 | -# ref: solar-system | ||
| 6 | -# type: radio | ||
| 7 | -# title: Sistema solar | ||
| 8 | -# text: Qual é o maior planeta do Sistema Solar? | ||
| 9 | -# options: | ||
| 10 | -# - Mercúrio | ||
| 11 | -# - Marte | ||
| 12 | -# - Júpiter | ||
| 13 | -# - Têm todos o mesmo tamanho | ||
| 14 | -# # opcional | ||
| 15 | -# correct: 2 | ||
| 16 | -# shuffle: False | ||
| 17 | -# discount: True | 3 | +# --------------------------------------------------------------------------- |
| 4 | +- | ||
| 5 | + ref: solar-system | ||
| 6 | + type: radio | ||
| 7 | + title: Sistema solar | ||
| 8 | + text: | | ||
| 9 | +  | ||
| 10 | + | ||
| 11 | + Qual é o maior planeta do Sistema Solar? | ||
| 12 | + options: | ||
| 13 | + - Mercúrio | ||
| 14 | + - Marte | ||
| 15 | + - Júpiter | ||
| 16 | + - Têm todos o mesmo tamanho | ||
| 17 | + # opcional | ||
| 18 | + correct: 2 | ||
| 19 | + shuffle: False | ||
| 20 | + discount: True | ||
| 18 | 21 | ||
| 19 | # # --------------------------------------------------------------------------- | 22 | # # --------------------------------------------------------------------------- |
| 20 | # - | 23 | # - |
serve.py
| @@ -30,8 +30,8 @@ class WebApplication(tornado.web.Application): | @@ -30,8 +30,8 @@ class WebApplication(tornado.web.Application): | ||
| 30 | (r'/change_password', ChangePasswordHandler), | 30 | (r'/change_password', ChangePasswordHandler), |
| 31 | (r'/question', QuestionHandler), # each question | 31 | (r'/question', QuestionHandler), # each question |
| 32 | (r'/topic/(.+)', TopicHandler), # page for doing a topic | 32 | (r'/topic/(.+)', TopicHandler), # page for doing a topic |
| 33 | - # (r'/file/(.+)', FileHandler), # FIXME | ||
| 34 | - (r'/.*', RootHandler), # show list of topics | 33 | + (r'/file/(.+)', FileHandler), # FIXME |
| 34 | + (r'/', RootHandler), # show list of topics | ||
| 35 | ] | 35 | ] |
| 36 | settings = { | 36 | settings = { |
| 37 | 'template_path': path.join(path.dirname(__file__), 'templates'), | 37 | 'template_path': path.join(path.dirname(__file__), 'templates'), |
| @@ -143,20 +143,36 @@ class TopicHandler(BaseHandler): | @@ -143,20 +143,36 @@ class TopicHandler(BaseHandler): | ||
| 143 | self.redirect('/') | 143 | self.redirect('/') |
| 144 | 144 | ||
| 145 | # ---------------------------------------------------------------------------- | 145 | # ---------------------------------------------------------------------------- |
| 146 | -# FIXME | 146 | +# Based on https://bhch.github.io/posts/2017/12/serving-large-files-with-tornado-safely-without-blocking/ |
| 147 | class FileHandler(BaseHandler): | 147 | class FileHandler(BaseHandler): |
| 148 | @tornado.web.authenticated | 148 | @tornado.web.authenticated |
| 149 | - def get(self, filename): | 149 | + async def get(self, filename): |
| 150 | uid = self.current_user | 150 | uid = self.current_user |
| 151 | public_dir = self.learn.get_current_public_dir(uid) | 151 | public_dir = self.learn.get_current_public_dir(uid) |
| 152 | filepath = path.expanduser(path.join(public_dir, filename)) | 152 | filepath = path.expanduser(path.join(public_dir, filename)) |
| 153 | + chunk_size = 1024 * 1024 # serve 1MiB multiple times | ||
| 153 | try: | 154 | try: |
| 154 | f = open(filepath, 'rb') | 155 | f = open(filepath, 'rb') |
| 155 | except FileNotFoundError: | 156 | except FileNotFoundError: |
| 156 | - raise tornado.web.HTTPError(404) | 157 | + logging.error(f'File not found: {filepath}') |
| 158 | + except PermissionError: | ||
| 159 | + logging.error(f'No permission: {filepath}') | ||
| 157 | else: | 160 | else: |
| 158 | - self.write(f.read()) | ||
| 159 | - f.close() | 161 | + with f: |
| 162 | + while True: | ||
| 163 | + chunk = f.read(chunk_size) | ||
| 164 | + if not chunk: break | ||
| 165 | + try: | ||
| 166 | + self.write(chunk) # write the cunk to response | ||
| 167 | + await self.flush() # flush the current chunk to socket | ||
| 168 | + except iostream.StreamClosedError: | ||
| 169 | + # client closed the connection | ||
| 170 | + break | ||
| 171 | + finally: | ||
| 172 | + del chunk | ||
| 173 | + await gen.sleep(0.000000001) # 1 nanosecond (hack) | ||
| 174 | + # in tornnado 5.0 use `await asyncio.sleep(0)` instead | ||
| 175 | + | ||
| 160 | 176 | ||
| 161 | # ---------------------------------------------------------------------------- | 177 | # ---------------------------------------------------------------------------- |
| 162 | # respond to AJAX to get a JSON question | 178 | # respond to AJAX to get a JSON question |
tools.py
| @@ -99,19 +99,10 @@ class HighlightRenderer(mistune.Renderer): | @@ -99,19 +99,10 @@ class HighlightRenderer(mistune.Renderer): | ||
| 99 | def table(self, header, body): | 99 | def table(self, header, body): |
| 100 | return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" | 100 | return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" |
| 101 | 101 | ||
| 102 | - # def image(self, src, title, text): | ||
| 103 | - # if src.startswith('javascript:'): | ||
| 104 | - # src = '' | ||
| 105 | - # text = mistune.escape(text, quote=True) | ||
| 106 | - # if title: | ||
| 107 | - # title = mistune.escape(title, quote=True) | ||
| 108 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s" title="%s"' % (src, text, title) | ||
| 109 | - # else: | ||
| 110 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s"' % (src, text) | ||
| 111 | - # if self.options.get('use_xhtml'): | ||
| 112 | - # return '%s />' % html | ||
| 113 | - # return '%s>' % html | ||
| 114 | - | 102 | + def image(self, src, title, alt): |
| 103 | + alt = mistune.escape(alt, quote=True) | ||
| 104 | + title = mistune.escape(title or '', quote=True) | ||
| 105 | + return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" alt="{alt}" title="{title}">' | ||
| 115 | 106 | ||
| 116 | # Pass math through unaltered - mathjax does the rendering in the browser | 107 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 117 | def block_math(self, text): | 108 | def block_math(self, text): |