You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

app.py 7.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. from datetime import datetime, timedelta
  2. import json
  3. import traceback
  4. import urllib
  5. import aiofiles
  6. import fastapi
  7. from fastapi import Cookie, File, Form, Request, UploadFile, WebSocket, WebSocketDisconnect
  8. from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, PlainTextResponse
  9. from fastapi.staticfiles import StaticFiles
  10. from fastapi.templating import Jinja2Templates
  11. from fastapi.middleware.cors import CORSMiddleware
  12. import reporthook
  13. import inventory
  14. import sbenv
  15. import searchlib
  16. MAX_SEARCH_DAYS = 180
  17. MAX_SHOW_DAYS = 20
  18. REPORT_HORIZON = 180
  19. MAX_USER_REPORTS_PER_DAY = 3
  20. ################################
  21. # Core configuration
  22. ################################
  23. app = fastapi.FastAPI(docs_url=None)
  24. origins = [
  25. 'https://prograde.gg',
  26. 'http://localhost',
  27. 'http://localhost:5000',
  28. 'http://localhost:8080',
  29. 'http://localhost:8000'
  30. ]
  31. app.add_middleware(
  32. CORSMiddleware,
  33. allow_origins=origins,
  34. allow_credentials=True,
  35. allow_methods=['*'],
  36. allow_headers=['*']
  37. )
  38. app.mount('/static', StaticFiles(directory='static'), name='static')
  39. tmplts = Jinja2Templates(directory='templates') # TODO Get the path correctly.
  40. ################################
  41. # User-facing endpoints
  42. ################################
  43. @app.exception_handler(Exception)
  44. async def handle_exception(req: Request, exc: Exception):
  45. tb = traceback.format_exc()
  46. await reporthook.send_report(tb)
  47. return PlainTextResponse('error', status_code=500)
  48. @app.get('/')
  49. async def render_main(req: Request):
  50. raw_articles = await load_recent_articles()
  51. converted = convert_days_from_articles(raw_articles)
  52. num_days = calc_num_days(converted)
  53. p = {
  54. 'sb': {
  55. 'num_days': num_days,
  56. 'days': converted
  57. },
  58. 'notices': [
  59. {
  60. 'style': 'primary',
  61. 'text': 'There were so many incidents in August 2021 that news sites stopped reporting on it, so there\'s some missing data here.',
  62. }
  63. ],
  64. 'request': req,
  65. }
  66. return tmplts.TemplateResponse('main.htm', p)
  67. @app.post('/action/flag')
  68. async def handle_flag(req: Request, date: str = Form(...), article: str = Form(...)):
  69. xff = req.headers['X-Forwarded-For'] if 'X-Forwarded-For' in req.headers else None
  70. ipaddr = xff if xff is not None else req.client.host
  71. try:
  72. today = datetime.now()
  73. pdate = datetime.strptime(date, inventory.DATE_FORMAT)
  74. if pdate > today or (today - pdate).days > REPORT_HORIZON:
  75. raise ValueError('bad date')
  76. except Exception as e:
  77. return JSONResponse({'status': 'error'}, status_code=400)
  78. flags = await inventory.load_date_flags_async(pdate)
  79. # Make sure it's not a duplicate and limit the number of reports
  80. nreporter = 0
  81. for e in flags:
  82. if e['src'] == ipaddr:
  83. if e['url'] == article:
  84. return {'status': 'OK'}
  85. nreporter += 1
  86. if nreporter + 1 >= MAX_USER_REPORTS_PER_DAY:
  87. print('user', ipaddr, 'looking sussy')
  88. await reporthook.send_report('address %s made more reports for %s than allowed' % (ipaddr, date))
  89. return JSONResponse({'status': 'error'}, status_code=429)
  90. await reporthook.send_report('address %s reported url %s' % (ipaddr, article))
  91. flags.append({
  92. 'src': ipaddr,
  93. 'url': article,
  94. })
  95. await inventory.save_date_flags_async(pdate, flags)
  96. return make_html_redirect_response('/')
  97. @app.post('/action/submit')
  98. async def handle_submit(req: Request, article: str = Form(...)):
  99. ipaddr = req.client.host
  100. today_str = datetime.now().strftime(inventory.DATE_FORMAT)
  101. fetched_art = searchlib.fetch_article(article)
  102. if fetched_art is None:
  103. return make_html_redirect_response('/')
  104. eff_date = fetched_art['nd'] if 'nd' in fetched_art else today_str
  105. # Now process it so we can tell that it's a definite match.
  106. proced_art = searchlib.process_day_results(eff_date, [fetched_art])
  107. print(proced_art)
  108. if len(proced_art['pass']) == 0:
  109. return make_html_redirect_response('/')
  110. # If it all looks good then store it and report it.
  111. fetched_art['srcip'] = ipaddr
  112. await add_article(eff_date, fetched_art)
  113. await reporthook.send_report('address %s submitted good-looking article %s' % (ipaddr, article))
  114. return make_html_redirect_response('/')
  115. ################################
  116. # API endpoints
  117. ################################
  118. @app.post('/api/addarticle')
  119. async def handle_addarticle(req: Request):
  120. if not check_admin_token(req):
  121. return JSONResponse(status_code=403, content={'error': 'forbidden'})
  122. body = await req.json()
  123. await add_article(body['date'], body['desc'])
  124. return {'status': 'OK'}
  125. async def add_article(datestr, adesc):
  126. date = datetime.strptime(datestr, inventory.DATE_FORMAT)
  127. articles = await inventory.load_date_report_async(date)
  128. articles.append(adesc)
  129. await inventory.save_date_report_async(date, articles)
  130. ################################
  131. # Utilities
  132. ################################
  133. def check_admin_token(req: Request):
  134. ak = sbenv.get_admin_key()
  135. if ak is None:
  136. raise RuntimeError('checked api endpoint without key loaded')
  137. if ak == 'UNSAFE_TESTING':
  138. return True
  139. if 'Authorization' in req.headers:
  140. auth = req.headers['Authorization']
  141. if not auth.startswith('Bearer '):
  142. return False
  143. tok = auth[len('Bearer '):]
  144. return tok == sbenv.get_admin_key()
  145. else:
  146. return False
  147. async def load_days_from_file(path):
  148. async with aiofiles.open(path, mode='r') as f:
  149. contents = await f.read()
  150. return json.loads(contents)
  151. async def load_recent_articles():
  152. today = datetime.now()
  153. day_dur = timedelta(days=1)
  154. reports = {}
  155. for i in range(MAX_SEARCH_DAYS):
  156. that_day = today - i * day_dur
  157. report = await inventory.load_date_report_async(that_day)
  158. flags = await inventory.load_date_flags_async(that_day)
  159. if len(report) > 0:
  160. reports[that_day.strftime(inventory.DATE_FORMAT)] = {
  161. 'articles': report,
  162. 'flags': flags,
  163. }
  164. return reports
  165. def convert_days_from_articles(days):
  166. output = []
  167. for dstr, parts in days.items():
  168. dr = searchlib.process_day_results(dstr, parts['articles'])
  169. flags = {e['url'] for e in parts['flags']}
  170. day = {
  171. 'date': dstr,
  172. 'links': [],
  173. 'maybe_links': []
  174. }
  175. # Process hard passes.
  176. for a in dr['pass']:
  177. ca = convert_article(a)
  178. if a['url'] not in flags:
  179. day['links'].append(ca)
  180. else:
  181. day['maybe_links'].append(ca)
  182. # Process weak articles.
  183. for a in dr['maybe']:
  184. ca = convert_article(a)
  185. if a['url'] not in flags:
  186. day['maybe_links'].append(ca)
  187. if len(day['links']) > 0:
  188. output.append(day)
  189. if len(output) > MAX_SHOW_DAYS:
  190. break
  191. return output
  192. def convert_article(a):
  193. u = a['url']
  194. uu = urllib.parse.urlparse(u)
  195. return {
  196. 'url': u,
  197. 'title': a['gtitle'],
  198. 'slug': a['slug'],
  199. 'domain': uu.netloc,
  200. }
  201. def calc_num_days(dayslist):
  202. today = datetime.now()
  203. lowest = -1
  204. for d in dayslist:
  205. pd = datetime.strptime(d['date'], inventory.DATE_FORMAT)
  206. diff = today - pd
  207. ndays = diff.days
  208. if ndays < lowest or lowest == -1:
  209. lowest = ndays
  210. return lowest
  211. def make_html_redirect_response(url):
  212. return HTMLResponse('<head><meta http-equiv="Refresh" content="0; URL=' + url + '"></head>')