pyapi.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. #
  2. # Copyright (c) Daniel Sheffield 2023
  3. # All rights reserved
  4. #
  5. # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
  6. from io import BufferedReader
  7. import os
  8. from threading import Thread
  9. from typing import Union
  10. from bottle import (
  11. route, request, response,
  12. redirect, abort,
  13. template, static_file,
  14. FormsDict, HTTPError,
  15. )
  16. from psycopg import Cursor, connect
  17. from psycopg.rows import TupleRow
  18. from .hash_util import blake, bytes_to_base32, hash_to_base32, hex_to_hash, normalize_base32
  19. from .route_decorators import normalize, normalize_query, poison, cursor
  20. from .query_to_xml import get_categories, get_groups, get_products, get_tags
  21. from .CachedLoadingPage import CachedLoadingPage
  22. from .Cache import Cache
  23. from . import BOOLEAN, PARAMS, trend as worker
  24. host = f"host={os.getenv('HOST')}"
  25. db = f"dbname={os.getenv('DB', 'grocery')}"
  26. user = f"user={os.getenv('USER', 'das')}"
  27. password = f"password={os.getenv('PASSWORD','')}"
  28. if not password.split('=',1)[1]:
  29. password = ''
  30. conn = connect(f"{host} {db} {user} {password}")
  31. @route('/grocery/static/<filename:path>')
  32. def send_static(filename):
  33. return static_file(filename, root='app/rest/static')
  34. @route('/grocery/trend', method=['GET', 'POST'])
  35. @poison(cache=Cache(10))
  36. @normalize
  37. def trend(key: str, forms: FormsDict, cache: Cache):
  38. page = cache[key]
  39. if page:
  40. return page
  41. _, _, path, *_ = request.urlparts
  42. return cache.add(key, CachedLoadingPage(
  43. template("loading", progress=[]),
  44. lambda queue: Thread(target=worker.trend, args=(
  45. queue, conn, path, forms
  46. )).start()
  47. ))
  48. @route('/grocery/groups', method=['GET', 'POST'])
  49. @poison(cache=Cache(10))
  50. @normalize
  51. @cursor(connection=conn)
  52. def groups(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
  53. response.content_type = 'application/xhtml+xml; charset=utf-8'
  54. return get_groups(cur, forms)
  55. @route('/grocery/categories', method=['GET', 'POST'])
  56. @poison(cache=Cache(10))
  57. @normalize
  58. @cursor(connection=conn)
  59. def categories(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
  60. response.content_type = 'application/xhtml+xml; charset=utf-8'
  61. return get_categories(cur, forms)
  62. @route('/grocery/products', method=['GET', 'POST'])
  63. @poison(cache=Cache(10))
  64. @normalize
  65. @cursor(connection=conn)
  66. def products(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
  67. response.content_type = 'application/xhtml+xml; charset=utf-8'
  68. return get_products(cur, forms)
  69. @route('/grocery/tags', method=['GET', 'POST'])
  70. @poison(cache=Cache(10))
  71. @normalize
  72. @cursor(connection=conn)
  73. def tags(cur: Cursor[TupleRow], key: Union[int, str], forms: FormsDict, cache: Cache):
  74. response.content_type = 'application/xhtml+xml; charset=utf-8'
  75. return get_tags(cur, forms)
  76. CLIP_SIZE_LIMIT = 65535
  77. SCHEME = "http://" #"https://"
  78. HOST = ""
  79. DOMAIN = "0.0.0.0" #"shandan.one"
  80. PORT = 6772 #""
  81. LOCATION = SCHEME + (f"{HOST}." if HOST else "") + DOMAIN + (f":{PORT}" if PORT else "")
  82. @route('/clip', method=['GET', 'POST'])
  83. @route('/clip/', method=['GET', 'POST'])
  84. def clip():
  85. if request.method == 'GET':
  86. _hash = request.params.hash
  87. if _hash:
  88. _hash = normalize_base32(_hash)
  89. content = validate(_hash).decode('utf-8')
  90. else:
  91. content = None
  92. link = f'{LOCATION}/clip/{_hash}' if content else f'{LOCATION}/clip'
  93. response.content_type = 'text/html; charset=utf-8'
  94. form = template(
  95. 'clip-form',
  96. action='/clip',
  97. method='post',
  98. content=content,
  99. disabled=True if content else False
  100. )
  101. return template(
  102. 'paste',
  103. form=form,
  104. link=link,
  105. disabled=True if content else False,
  106. download=f'/clip/{_hash}' if content else None
  107. )
  108. if request.method == 'POST':
  109. if 'paste' not in request.params:
  110. return abort(400, "Missing parameter: 'paste'")
  111. # TODO: what is correct overhead for form content?
  112. OVERHEAD = 1024
  113. if 'paste' not in request.query and request.content_length == -1 or request.content_length > CLIP_SIZE_LIMIT + OVERHEAD:
  114. return abort(418, f"Paste size can not exceed {CLIP_SIZE_LIMIT}")
  115. content = request.params['paste'].encode('utf-8')
  116. if len(content) > CLIP_SIZE_LIMIT:
  117. return abort(418, f"Paste size can not exceed {CLIP_SIZE_LIMIT}")
  118. _bytes = blake(content, person='clip'.encode('utf-8'))
  119. _b32 = bytes_to_base32(_bytes)
  120. directory = f'app/rest/static/{_b32}'
  121. try:
  122. os.mkdir(directory, mode=0o700, dir_fd=None)
  123. except FileExistsError:
  124. pass
  125. fd = os.open(f'{directory}/{_b32}.file', os.O_WRONLY | os.O_TRUNC | os.O_CREAT, 0o600)
  126. with open(fd, "wb") as f:
  127. f.write(content)
  128. form = template('clip-form', action='/clip', method='post', content=content)
  129. response.content_type = 'text/html; charset=utf-8'
  130. #return HTTPResponse(template('paste', form=form, link=f'{LOCATION}/clip/{_b32}'), 201)
  131. return redirect(f'/clip?hash={_b32}')
  132. def validate(filename: str) -> bytes:
  133. ret = static_file('/'.join([filename,]*2) + '.file', root='app/rest/static')
  134. if isinstance(ret, HTTPError):
  135. return abort(404, f"No such paste: {filename}")
  136. if ret.content_length > CLIP_SIZE_LIMIT:
  137. return abort(418, f"Paste size exceeds {CLIP_SIZE_LIMIT}")
  138. content: bytes = ret.body.read() if isinstance(ret.body, BufferedReader) else ret.body.encode('utf-8')
  139. _bytes = blake(content, person='clip'.encode('utf-8'))
  140. _b32 = bytes_to_base32(_bytes)
  141. if _b32 != filename:
  142. return abort(410, f"Paste content differs")
  143. return content
  144. @route('/clip/<filename:path>', method='GET')
  145. def get_clip(filename):
  146. filename = normalize_base32(filename)
  147. if not request.params.raw.lower() == 'true':
  148. # TODO: return a form with timeout to a GET instead ?
  149. return redirect(f'/clip?hash={filename}')
  150. ret = validate(filename)
  151. if isinstance(ret, HTTPError):
  152. return ret
  153. return static_file('/'.join([filename,]*2) + '.file', root='app/rest/static')