cista 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. cista/_version.py +1 -1
  2. cista/api.py +16 -5
  3. cista/app.py +40 -27
  4. cista/auth.py +33 -1
  5. cista/preview.py +114 -0
  6. cista/protocol.py +5 -2
  7. cista/serve.py +1 -1
  8. cista/util/apphelpers.py +3 -3
  9. cista/watching.py +271 -210
  10. cista/wwwroot/assets/{add-file-78f95102.js → add-file-e19e18ff.js} +1 -1
  11. cista/wwwroot/assets/{add-folder-ef91bb0e.js → add-folder-cf930a27.js} +1 -1
  12. cista/wwwroot/assets/{arrow-c279db10.js → arrow-f09bf4bc.js} +1 -1
  13. cista/wwwroot/assets/{arrows-h-0090937e.js → arrows-h-74714fc1.js} +1 -1
  14. cista/wwwroot/assets/{arrows-v-05d0dc3e.js → arrows-v-900e3e20.js} +1 -1
  15. cista/wwwroot/assets/{check-736f9156.js → check-5d5030f9.js} +1 -1
  16. cista/wwwroot/assets/{code-f442e1ae.js → code-08d0327a.js} +1 -1
  17. cista/wwwroot/assets/{copy-f9304d76.js → copy-d4e131a3.js} +1 -1
  18. cista/wwwroot/assets/{create-file-5cfbca5b.js → create-file-7ee36655.js} +1 -1
  19. cista/wwwroot/assets/{create-folder-f3416e7f.js → create-folder-4baad31c.js} +1 -1
  20. cista/wwwroot/assets/{cross-34310f33.js → cross-5dc70d0a.js} +1 -1
  21. cista/wwwroot/assets/{disk-9de14290.js → disk-1c8c6bb2.js} +1 -1
  22. cista/wwwroot/assets/{download-40f6e8d9.js → download-4c58df40.js} +1 -1
  23. cista/wwwroot/assets/{exclamation-67d2f487.js → exclamation-b23fc5a1.js} +1 -1
  24. cista/wwwroot/assets/{eye-1a9cae12.js → eye-fc97097a.js} +1 -1
  25. cista/wwwroot/assets/{find-f49bedc3.js → find-9f5d36c7.js} +1 -1
  26. cista/wwwroot/assets/{fullscreen-6e83f85d.js → fullscreen-72bac89d.js} +1 -1
  27. cista/wwwroot/assets/{github-33da7a52.js → github-8781f34a.js} +1 -1
  28. cista/wwwroot/assets/index-5ab4ce9a.css +1 -0
  29. cista/wwwroot/assets/index-c828fba8.js +13 -0
  30. cista/wwwroot/assets/{info-9a003b6c.js → info-81c1e2fa.js} +1 -1
  31. cista/wwwroot/assets/{link-b5bf40a7.js → link-ddc2f9ba.js} +1 -1
  32. cista/wwwroot/assets/{logo-adde2ca4.js → logo-10d7b218.js} +1 -1
  33. cista/wwwroot/assets/{loop-762ab430.js → loop-a579040b.js} +1 -1
  34. cista/wwwroot/assets/{menu-633b2a61.js → menu-cb4bdef2.js} +1 -1
  35. cista/wwwroot/assets/{next-d9d1d510.js → next-bb1c5152.js} +1 -1
  36. cista/wwwroot/assets/{open-91351e45.js → open-f8e4da33.js} +1 -1
  37. cista/wwwroot/assets/{paste-d3f337c1.js → paste-0bef6dfd.js} +1 -1
  38. cista/wwwroot/assets/{pause-8f82e536.js → pause-27898a74.js} +1 -1
  39. cista/wwwroot/assets/{pencil-0b434534.js → pencil-19d33c49.js} +1 -1
  40. cista/wwwroot/assets/{play-e0e51167.js → play-fe6706ce.js} +1 -1
  41. cista/wwwroot/assets/{plus-e2a2ec0f.js → plus-ab9d4dbd.js} +1 -1
  42. cista/wwwroot/assets/{previous-cd61ebe6.js → previous-5be4e762.js} +1 -1
  43. cista/wwwroot/assets/{reload-a9c668b2.js → reload-84455f3f.js} +1 -1
  44. cista/wwwroot/assets/{rename-bd15329b.js → rename-88ab1b4d.js} +1 -1
  45. cista/wwwroot/assets/{scissors-dcbf78c0.js → scissors-1266cf56.js} +1 -1
  46. cista/wwwroot/assets/{shuffle-74e9ea1c.js → shuffle-0412a143.js} +1 -1
  47. cista/wwwroot/assets/{signin-bbf26a1b.js → signin-dcc16c88.js} +1 -1
  48. cista/wwwroot/assets/{signout-caa34d68.js → signout-935b6b65.js} +1 -1
  49. cista/wwwroot/assets/{skip-423d5cf0.js → skip-adeae5b8.js} +1 -1
  50. cista/wwwroot/assets/{spinner-b299e14e.js → spinner-7c5e1e66.js} +1 -1
  51. cista/wwwroot/assets/{stop-91578a62.js → stop-6ec4fac4.js} +1 -1
  52. cista/wwwroot/assets/{trash-3b7b72a3.js → trash-218fd3df.js} +1 -1
  53. cista/wwwroot/assets/{triangle-724a2314.js → triangle-40a425a9.js} +1 -1
  54. cista/wwwroot/assets/{unfullscreen-29f4977c.js → unfullscreen-3d51fed5.js} +1 -1
  55. cista/wwwroot/assets/{up-arrow-ceb58d59.js → up-arrow-af385124.js} +1 -1
  56. cista/wwwroot/assets/{upload-cloud-936fb8b2.js → upload-cloud-68d0fe9f.js} +1 -1
  57. cista/wwwroot/assets/{user-cog-887c6f3f.js → user-cog-bca0b085.js} +1 -1
  58. cista/wwwroot/assets/{user-ab4ed9ac.js → user-dd4fef53.js} +1 -1
  59. cista/wwwroot/assets/{volume-high-74a17568.js → volume-high-0f8f0e3d.js} +1 -1
  60. cista/wwwroot/assets/{volume-low-f7170d5f.js → volume-low-b1bd663e.js} +1 -1
  61. cista/wwwroot/assets/{volume-medium-7b16c1db.js → volume-medium-cd88f329.js} +1 -1
  62. cista/wwwroot/assets/{volume-mute-0c7078a1.js → volume-mute-458a2f48.js} +1 -1
  63. cista/wwwroot/assets/{window-f7f79ada.js → window-b63b3c5a.js} +1 -1
  64. cista/wwwroot/assets/{window-cross-e3a75b33.js → window-cross-b0bd1b60.js} +1 -1
  65. cista/wwwroot/assets/{wordwrap-3bcce83c.js → wordwrap-26b55346.js} +1 -1
  66. cista/wwwroot/assets/{zoomin-bd27188c.js → zoomin-cb10ed9f.js} +1 -1
  67. cista/wwwroot/assets/{zoomout-31844707.js → zoomout-90bcc471.js} +1 -1
  68. cista/wwwroot/index.html +3 -4
  69. {cista-0.5.0.dist-info → cista-0.7.0.dist-info}/METADATA +15 -10
  70. cista-0.7.0.dist-info/RECORD +86 -0
  71. cista/wwwroot/assets/cog-fcdd928d.js +0 -1
  72. cista/wwwroot/assets/index-6716899e.js +0 -11
  73. cista/wwwroot/assets/index-bcee9add.css +0 -1
  74. cista-0.5.0.dist-info/RECORD +0 -86
  75. {cista-0.5.0.dist-info → cista-0.7.0.dist-info}/WHEEL +0 -0
  76. {cista-0.5.0.dist-info → cista-0.7.0.dist-info}/entry_points.txt +0 -0
cista/_version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by hatch build.
2
- __version__ = '0.5.0'
2
+ __version__ = '0.7.0'
cista/api.py CHANGED
@@ -111,13 +111,24 @@ async def watch(req, ws):
111
111
  )
112
112
  uuid = token_bytes(16)
113
113
  try:
114
- with watching.state.lock:
115
- q = watching.pubsub[uuid] = asyncio.Queue()
116
- # Init with disk usage and full tree
117
- await ws.send(watching.format_space(watching.state.space))
118
- await ws.send(watching.format_root(watching.state.root))
114
+ q, space, root = await asyncio.get_event_loop().run_in_executor(
115
+ req.app.ctx.threadexec, subscribe, uuid, ws
116
+ )
117
+ await ws.send(space)
118
+ await ws.send(root)
119
119
  # Send updates
120
120
  while True:
121
121
  await ws.send(await q.get())
122
122
  finally:
123
123
  del watching.pubsub[uuid]
124
+
125
+
126
+ def subscribe(uuid, ws):
127
+ with watching.state.lock:
128
+ q = watching.pubsub[uuid] = asyncio.Queue()
129
+ # Init with disk usage and full tree
130
+ return (
131
+ q,
132
+ watching.format_space(watching.state.space),
133
+ watching.format_root(watching.state.root),
134
+ )
cista/app.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import datetime
3
3
  import mimetypes
4
+ import threading
4
5
  from concurrent.futures import ThreadPoolExecutor
5
6
  from pathlib import Path, PurePath, PurePosixPath
6
7
  from stat import S_IFDIR, S_IFREG
@@ -10,12 +11,12 @@ from wsgiref.handlers import format_date_time
10
11
  import brotli
11
12
  import sanic.helpers
12
13
  from blake3 import blake3
13
- from sanic import Blueprint, Sanic, empty, raw
14
+ from sanic import Blueprint, Sanic, empty, raw, redirect
14
15
  from sanic.exceptions import Forbidden, NotFound
15
- from sanic.log import logging
16
+ from sanic.log import logger
16
17
  from stream_zip import ZIP_AUTO, stream_zip
17
18
 
18
- from cista import auth, config, session, watching
19
+ from cista import auth, config, preview, session, watching
19
20
  from cista.api import bp
20
21
  from cista.util.apphelpers import handle_sanic_exception
21
22
 
@@ -24,6 +25,7 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
24
25
 
25
26
  app = Sanic("cista", strict_slashes=True)
26
27
  app.blueprint(auth.bp)
28
+ app.blueprint(preview.bp)
27
29
  app.blueprint(bp)
28
30
  app.exception(Exception)(handle_sanic_exception)
29
31
 
@@ -31,14 +33,15 @@ app.exception(Exception)(handle_sanic_exception)
31
33
  @app.before_server_start
32
34
  async def main_start(app, loop):
33
35
  config.load_config()
34
- await watching.start(app, loop)
35
36
  app.ctx.threadexec = ThreadPoolExecutor(
36
37
  max_workers=8, thread_name_prefix="cista-ioworker"
37
38
  )
39
+ await watching.start(app, loop)
38
40
 
39
41
 
40
42
  @app.after_server_stop
41
43
  async def main_stop(app, loop):
44
+ quit.set()
42
45
  await watching.stop(app, loop)
43
46
  app.ctx.threadexec.shutdown()
44
47
 
@@ -122,7 +125,7 @@ def _load_wwwroot(www):
122
125
  if not wwwnew:
123
126
  msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
124
127
  if not www:
125
- logging.warning(msg)
128
+ logger.warning(msg)
126
129
  if not app.debug:
127
130
  msg = "Web frontend missing. Cista installation is broken.\n"
128
131
  wwwnew[""] = (
@@ -141,7 +144,7 @@ def _load_wwwroot(www):
141
144
  async def start(app):
142
145
  await load_wwwroot(app)
143
146
  if app.debug:
144
- app.add_task(refresh_wwwroot())
147
+ app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
145
148
 
146
149
 
147
150
  async def load_wwwroot(app):
@@ -151,27 +154,31 @@ async def load_wwwroot(app):
151
154
  )
152
155
 
153
156
 
157
+ quit = threading.Event()
158
+
159
+
154
160
  async def refresh_wwwroot():
155
- while True:
156
- await asyncio.sleep(0.5)
157
- try:
158
- wwwold = www
159
- await load_wwwroot(app)
160
- changes = ""
161
- for name in sorted(www):
162
- attr = www[name]
163
- if wwwold.get(name) == attr:
164
- continue
165
- headers = attr[2]
166
- changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
167
- for name in sorted(set(wwwold) - set(www)):
168
- changes += f"Deleted /{name}\n"
169
- if changes:
170
- print(f"Updated wwwroot:\n{changes}", end="", flush=True)
171
- except Exception as e:
172
- print("Error loading wwwroot", e)
173
- if not app.debug:
174
- return
161
+ try:
162
+ while not quit.is_set():
163
+ try:
164
+ wwwold = www
165
+ await load_wwwroot(app)
166
+ changes = ""
167
+ for name in sorted(www):
168
+ attr = www[name]
169
+ if wwwold.get(name) == attr:
170
+ continue
171
+ headers = attr[2]
172
+ changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
173
+ for name in sorted(set(wwwold) - set(www)):
174
+ changes += f"Deleted /{name}\n"
175
+ if changes:
176
+ print(f"Updated wwwroot:\n{changes}", end="", flush=True)
177
+ except Exception as e:
178
+ print(f"Error loading wwwroot: {e!r}")
179
+ await asyncio.sleep(0.5)
180
+ except asyncio.CancelledError:
181
+ pass
175
182
 
176
183
 
177
184
  @app.route("/<path:path>", methods=["GET", "HEAD"])
@@ -191,6 +198,12 @@ async def wwwroot(req, path=""):
191
198
  return raw(data, headers=headers)
192
199
 
193
200
 
201
+ @app.route("/favicon.ico", methods=["GET", "HEAD"])
202
+ async def favicon(req):
203
+ # Browsers keep asking for it when viewing files (not HTML with icon link)
204
+ return redirect("/assets/logo-97d1d7eb.svg", status=308)
205
+
206
+
194
207
  def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
195
208
  loc = PurePosixPath()
196
209
  idx = 0
@@ -251,7 +264,7 @@ async def zip_download(req, keys, zipfile, ext):
251
264
  for chunk in stream_zip(local_files(files)):
252
265
  asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
253
266
  except Exception:
254
- logging.exception("Error streaming ZIP")
267
+ logger.exception("Error streaming ZIP")
255
268
  raise
256
269
  finally:
257
270
  asyncio.run_coroutine_threadsafe(queue.put(None), loop)
cista/auth.py CHANGED
@@ -71,7 +71,7 @@ def verify(request, *, privileged=False):
71
71
  raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
72
72
  elif config.config.public or request.ctx.user:
73
73
  return
74
- raise Unauthorized("Login required", "cookie", quiet=True)
74
+ raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
75
75
 
76
76
 
77
77
  bp = Blueprint("auth")
@@ -159,3 +159,35 @@ async def logout_post(request):
159
159
  res = json({"message": msg})
160
160
  session.delete(res)
161
161
  return res
162
+
163
+
164
+ @bp.post("/password-change")
165
+ async def change_password(request):
166
+ try:
167
+ if request.headers.content_type == "application/json":
168
+ username = request.json["username"]
169
+ pwchange = request.json["passwordChange"]
170
+ password = request.json["password"]
171
+ else:
172
+ username = request.form["username"][0]
173
+ pwchange = request.form["passwordChange"][0]
174
+ password = request.form["password"][0]
175
+ if not username or not password:
176
+ raise KeyError
177
+ except KeyError:
178
+ raise BadRequest(
179
+ "Missing username, passwordChange or password",
180
+ ) from None
181
+ try:
182
+ user = login(username, password)
183
+ set_password(user, pwchange)
184
+ except ValueError as e:
185
+ raise Forbidden(str(e), context={"redirect": "/login"}) from e
186
+
187
+ if "text/html" in request.headers.accept:
188
+ res = redirect("/")
189
+ session.flash(res, "Password updated")
190
+ else:
191
+ res = json({"message": "Password updated"})
192
+ session.create(res, username)
193
+ return res
cista/preview.py ADDED
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import io
3
+ import mimetypes
4
+ import urllib.parse
5
+ from pathlib import PurePosixPath
6
+ from urllib.parse import unquote
7
+ from wsgiref.handlers import format_date_time
8
+
9
+ import av
10
+ import av.datasets
11
+ import fitz # PyMuPDF
12
+ from PIL import Image
13
+ from sanic import Blueprint, empty, raw
14
+ from sanic.exceptions import NotFound
15
+ from sanic.log import logger
16
+
17
+ from cista import config
18
+ from cista.util.filename import sanitize
19
+
20
+ bp = Blueprint("preview", url_prefix="/preview")
21
+
22
+
23
+ @bp.get("/<path:path>")
24
+ async def preview(req, path):
25
+ """Preview a file"""
26
+ maxsize = int(req.args.get("px", 1024))
27
+ maxzoom = float(req.args.get("zoom", 2.0))
28
+ quality = int(req.args.get("q", 40))
29
+ rel = PurePosixPath(sanitize(unquote(path)))
30
+ path = config.config.path / rel
31
+ stat = path.lstat()
32
+ etag = config.derived_secret(
33
+ "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
34
+ ).hex()
35
+ savename = PurePosixPath(path.name).with_suffix(".webp")
36
+ headers = {
37
+ "etag": etag,
38
+ "last-modified": format_date_time(stat.st_mtime),
39
+ "cache-control": "max-age=604800, immutable"
40
+ + ("" if config.config.public else ", private"),
41
+ "content-type": "image/webp",
42
+ "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
43
+ }
44
+ if req.headers.if_none_match == etag:
45
+ # The client has it cached, respond 304 Not Modified
46
+ return empty(304, headers=headers)
47
+
48
+ if not path.is_file():
49
+ raise NotFound("File not found")
50
+
51
+ img = await asyncio.get_event_loop().run_in_executor(
52
+ req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
53
+ )
54
+ return raw(img, headers=headers)
55
+
56
+
57
+ def dispatch(path, quality, maxsize, maxzoom):
58
+ if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
59
+ return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
60
+ if mimetypes.guess_type(path.name)[0].startswith("video/"):
61
+ return process_video(path, quality=quality, maxsize=maxsize)
62
+ return process_image(path, quality=quality, maxsize=maxsize)
63
+
64
+
65
+ def process_image(path, *, maxsize, quality):
66
+ img = Image.open(path)
67
+ w, h = img.size
68
+ img.thumbnail((min(w, maxsize), min(h, maxsize)))
69
+ # Fix rotation based on EXIF data
70
+ try:
71
+ rotate_values = {3: 180, 6: 270, 8: 90}
72
+ orientation = img._getexif().get(274)
73
+ if orientation in rotate_values:
74
+ logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
75
+ img = img.rotate(rotate_values[orientation], expand=True)
76
+ except AttributeError:
77
+ ...
78
+ except Exception as e:
79
+ logger.error(f"Error rotating preview image: {e}")
80
+ # Save as webp
81
+ imgdata = io.BytesIO()
82
+ img.save(imgdata, format="webp", quality=quality, method=4)
83
+ return imgdata.getvalue()
84
+
85
+
86
+ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
87
+ pdf = fitz.open(path)
88
+ page = pdf.load_page(page_number)
89
+ w, h = page.rect[2:4]
90
+ zoom = min(maxsize / w, maxsize / h, maxzoom)
91
+ mat = fitz.Matrix(zoom, zoom)
92
+ pix = page.get_pixmap(matrix=mat)
93
+ return pix.pil_tobytes(format="webp", quality=quality, method=4)
94
+
95
+
96
+ def process_video(path, *, maxsize, quality):
97
+ with av.open(str(path)) as container:
98
+ stream = container.streams.video[0]
99
+ rotation = (
100
+ stream.side_data
101
+ and stream.side_data.get(av.stream.SideData.DISPLAYMATRIX)
102
+ or 0
103
+ )
104
+ stream.codec_context.skip_frame = "NONKEY"
105
+ container.seek(container.duration // 8)
106
+ frame = next(container.decode(stream))
107
+ img = frame.to_image()
108
+
109
+ img.thumbnail((maxsize, maxsize))
110
+ imgdata = io.BytesIO()
111
+ if rotation:
112
+ img = img.rotate(rotation, expand=True)
113
+ img.save(imgdata, format="webp", quality=quality, method=4)
114
+ return imgdata.getvalue()
cista/protocol.py CHANGED
@@ -112,7 +112,7 @@ class ErrorMsg(msgspec.Struct):
112
112
  ## Directory listings
113
113
 
114
114
 
115
- class FileEntry(msgspec.Struct, array_like=True):
115
+ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
116
116
  level: int
117
117
  name: str
118
118
  key: str
@@ -120,9 +120,12 @@ class FileEntry(msgspec.Struct, array_like=True):
120
120
  size: int
121
121
  isfile: int
122
122
 
123
- def __repr__(self):
123
+ def __str__(self):
124
124
  return self.key or "FileEntry()"
125
125
 
126
+ def __repr__(self):
127
+ return f"{self.name} ({self.size}, {self.mtime})"
128
+
126
129
 
127
130
  class Update(msgspec.Struct, array_like=True):
128
131
  ...
cista/serve.py CHANGED
@@ -51,7 +51,7 @@ def parse_listen(listen):
51
51
  raise ValueError(
52
52
  f"Directory for unix socket does not exist: {unix.parent}/",
53
53
  )
54
- return "http://localhost", {"unix": unix}
54
+ return "http://localhost", {"unix": unix.as_posix()}
55
55
  if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
56
56
  return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
57
57
  try:
cista/util/apphelpers.py CHANGED
@@ -21,7 +21,6 @@ def jres(data, **kwargs):
21
21
 
22
22
 
23
23
  async def handle_sanic_exception(request, e):
24
- logger.exception(e)
25
24
  context, code = {}, 500
26
25
  message = str(e)
27
26
  if isinstance(e, SanicException):
@@ -42,7 +41,7 @@ async def handle_sanic_exception(request, e):
42
41
  res.cookies.add_cookie("message", message, max_age=5)
43
42
  return res
44
43
  # Otherwise use Sanic's default error page
45
- return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
44
+ return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
46
45
 
47
46
 
48
47
  def websocket_wrapper(handler):
@@ -54,13 +53,14 @@ def websocket_wrapper(handler):
54
53
  auth.verify(request)
55
54
  await handler(request, ws, *args, **kwargs)
56
55
  except Exception as e:
57
- logger.exception(e)
58
56
  context, code, message = {}, 500, str(e) or "Internal Server Error"
59
57
  if isinstance(e, SanicException):
60
58
  context = e.context or {}
61
59
  code = e.status_code
62
60
  message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
63
61
  await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
62
+ if not getattr(e, "quiet", False) or code == 500:
63
+ logger.exception(f"{code} {e!r}")
64
64
  raise
65
65
 
66
66
  return wrapper