cista 1.2.1__py3-none-any.whl → 1.4.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 (79) hide show
  1. cista/__main__.py +7 -5
  2. cista/_version.py +1 -1
  3. cista/api.py +39 -10
  4. cista/app.py +32 -10
  5. cista/auth.py +262 -58
  6. cista/config.py +9 -1
  7. cista/frontend-build/assets/icons-DMD182WZ.js +1 -0
  8. cista/frontend-build/assets/index-D4WrmH4f.js +32 -0
  9. cista/frontend-build/assets/index-DQ2iYrs-.css +1 -0
  10. cista/{wwwroot → frontend-build}/index.html +5 -5
  11. cista/preview.py +71 -41
  12. cista/sso.py +324 -0
  13. cista/util/apphelpers.py +5 -2
  14. cista/watching.py +2 -2
  15. {cista-1.2.1.dist-info → cista-1.4.0.dist-info}/METADATA +6 -2
  16. cista-1.4.0.dist-info/RECORD +32 -0
  17. {cista-1.2.1.dist-info → cista-1.4.0.dist-info}/WHEEL +1 -1
  18. cista/wwwroot/assets/add-file-8c95935e.js +0 -1
  19. cista/wwwroot/assets/add-folder-86d38756.js +0 -1
  20. cista/wwwroot/assets/arrow-75fe0244.js +0 -1
  21. cista/wwwroot/assets/arrows-h-7cc7ed45.js +0 -1
  22. cista/wwwroot/assets/arrows-v-6ba20f93.js +0 -1
  23. cista/wwwroot/assets/check-2847ad21.js +0 -1
  24. cista/wwwroot/assets/code-a7719923.js +0 -1
  25. cista/wwwroot/assets/copy-3449d27d.js +0 -1
  26. cista/wwwroot/assets/create-file-a1cd75fd.js +0 -1
  27. cista/wwwroot/assets/create-folder-59db9ded.js +0 -1
  28. cista/wwwroot/assets/cross-38ada808.js +0 -1
  29. cista/wwwroot/assets/disk-39a6808b.js +0 -1
  30. cista/wwwroot/assets/download-dfa3cb2c.js +0 -1
  31. cista/wwwroot/assets/exclamation-1b2064ee.js +0 -1
  32. cista/wwwroot/assets/eye-8ad3b932.js +0 -1
  33. cista/wwwroot/assets/find-7e0bf1e0.js +0 -1
  34. cista/wwwroot/assets/fullscreen-e21f0ea1.js +0 -1
  35. cista/wwwroot/assets/github-33a0fb51.js +0 -1
  36. cista/wwwroot/assets/index-1cae47bc.css +0 -1
  37. cista/wwwroot/assets/index-5b083aad.js +0 -28
  38. cista/wwwroot/assets/info-f30cd4b4.js +0 -1
  39. cista/wwwroot/assets/link-3a6a01b8.js +0 -1
  40. cista/wwwroot/assets/logo-a02885a3.js +0 -1
  41. cista/wwwroot/assets/loop-17dda54a.js +0 -1
  42. cista/wwwroot/assets/menu-e088a6cc.js +0 -1
  43. cista/wwwroot/assets/next-c83214f4.js +0 -1
  44. cista/wwwroot/assets/open-bdcf30c3.js +0 -1
  45. cista/wwwroot/assets/paste-b1ebeeae.js +0 -1
  46. cista/wwwroot/assets/pause-c44d983d.js +0 -1
  47. cista/wwwroot/assets/pencil-70ceb3cc.js +0 -1
  48. cista/wwwroot/assets/play-38e640b3.js +0 -1
  49. cista/wwwroot/assets/plus-d2e20553.js +0 -1
  50. cista/wwwroot/assets/previous-78dea9b8.js +0 -1
  51. cista/wwwroot/assets/reload-61c8cee7.js +0 -1
  52. cista/wwwroot/assets/rename-202f8bc5.js +0 -1
  53. cista/wwwroot/assets/scissors-9235051c.js +0 -1
  54. cista/wwwroot/assets/shuffle-fd56ec51.js +0 -1
  55. cista/wwwroot/assets/signin-494e0573.js +0 -1
  56. cista/wwwroot/assets/signout-4047ccde.js +0 -1
  57. cista/wwwroot/assets/skip-bb8e721d.js +0 -1
  58. cista/wwwroot/assets/spinner-8c3861ee.js +0 -1
  59. cista/wwwroot/assets/stop-d670a372.js +0 -1
  60. cista/wwwroot/assets/trash-6a339a1b.js +0 -1
  61. cista/wwwroot/assets/triangle-9358f5e4.js +0 -1
  62. cista/wwwroot/assets/unfullscreen-792a694f.js +0 -1
  63. cista/wwwroot/assets/up-arrow-f518550f.js +0 -1
  64. cista/wwwroot/assets/upload-cloud-5f1a71c5.js +0 -1
  65. cista/wwwroot/assets/user-2d17adce.js +0 -1
  66. cista/wwwroot/assets/user-cog-5cefd313.js +0 -1
  67. cista/wwwroot/assets/volume-high-151aaf83.js +0 -1
  68. cista/wwwroot/assets/volume-low-8ffa3894.js +0 -1
  69. cista/wwwroot/assets/volume-medium-7b38c87d.js +0 -1
  70. cista/wwwroot/assets/volume-mute-4e81ae44.js +0 -1
  71. cista/wwwroot/assets/window-cross-ce8f66c3.js +0 -1
  72. cista/wwwroot/assets/window-dec8f28e.js +0 -1
  73. cista/wwwroot/assets/wordwrap-4618846d.js +0 -1
  74. cista/wwwroot/assets/zoomin-d821154d.js +0 -1
  75. cista/wwwroot/assets/zoomout-faf726ac.js +0 -1
  76. cista-1.2.1.dist-info/RECORD +0 -86
  77. /cista/{wwwroot/assets/logo-97d1d7eb.svg → frontend-build/assets/logo-ctv8tVwU.svg} +0 -0
  78. /cista/{wwwroot → frontend-build}/robots.txt +0 -0
  79. {cista-1.2.1.dist-info → cista-1.4.0.dist-info}/entry_points.txt +0 -0
cista/preview.py CHANGED
@@ -13,17 +13,24 @@ import fitz # PyMuPDF
13
13
  import numpy as np
14
14
  import pillow_heif
15
15
  from PIL import Image
16
- from sanic import Blueprint, empty, raw
16
+ from sanic import Blueprint, empty, raw, redirect
17
17
  from sanic.exceptions import NotFound
18
18
  from sanic.log import logger
19
19
 
20
- from cista import config
20
+ from cista import auth, config
21
21
  from cista.util.filename import sanitize
22
22
 
23
23
  pillow_heif.register_heif_opener()
24
24
 
25
25
  bp = Blueprint("preview", url_prefix="/preview")
26
26
 
27
+
28
+ @bp.on_request
29
+ async def verify_preview(request):
30
+ """Verify access to preview routes."""
31
+ await auth.verify(request)
32
+
33
+
27
34
  # Map EXIF Orientation value to a corresponding PIL transpose
28
35
  EXIF_ORI = {
29
36
  2: Image.Transpose.FLIP_LEFT_RIGHT,
@@ -43,12 +50,12 @@ async def preview(req, path):
43
50
  maxzoom = float(req.args.get("zoom", 2.0))
44
51
  quality = int(req.args.get("q", 60))
45
52
  rel = PurePosixPath(sanitize(unquote(path)))
46
- path = config.config.path / rel
47
- stat = path.lstat()
53
+ filepath = config.config.path / rel
54
+ stat = filepath.lstat()
48
55
  etag = config.derived_secret(
49
56
  "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
50
57
  ).hex()
51
- savename = PurePosixPath(path.name).with_suffix(".avif")
58
+ savename = PurePosixPath(filepath.name).with_suffix(".avif")
52
59
  headers = {
53
60
  "etag": etag,
54
61
  "last-modified": format_date_time(stat.st_mtime),
@@ -61,22 +68,30 @@ async def preview(req, path):
61
68
  # The client has it cached, respond 304 Not Modified
62
69
  return empty(304, headers=headers)
63
70
 
64
- if not path.is_file():
71
+ if not filepath.is_file():
65
72
  raise NotFound("File not found")
66
73
 
67
74
  img = await asyncio.get_event_loop().run_in_executor(
68
- req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
75
+ req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
69
76
  )
77
+ if not img:
78
+ # Preview generation failed, redirect to the file itself
79
+ return redirect(f"/files/{path}", status=303)
70
80
  return raw(img, headers=headers)
71
81
 
72
82
 
73
83
  def dispatch(path, quality, maxsize, maxzoom):
74
- if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
75
- return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
76
- type, _ = mimetypes.guess_type(path.name)
77
- if type and type.startswith("video/"):
78
- return process_video(path, quality=quality, maxsize=maxsize)
79
- return process_image(path, quality=quality, maxsize=maxsize)
84
+ try:
85
+ if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
86
+ return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
87
+ type, _ = mimetypes.guess_type(path.name)
88
+ if type and type.startswith("video/"):
89
+ return process_video(path, quality=quality, maxsize=maxsize)
90
+ return process_image(path, quality=quality, maxsize=maxsize)
91
+ except ValueError as e:
92
+ logger.warning(f"Cannot generate preview for {path.name}: {e}")
93
+ except Exception as e:
94
+ logger.exception(f"Error generating preview for {path.name}: {e}")
80
95
 
81
96
 
82
97
  def process_image(path, *, maxsize, quality):
@@ -121,7 +136,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
121
136
  w, h = page.rect[2:4]
122
137
  zoom = min(maxsize / w, maxsize / h, maxzoom)
123
138
  mat = fitz.Matrix(zoom, zoom)
124
- pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]
139
+ pix = page.get_pixmap(matrix=mat)
125
140
  t_load_end = perf_counter()
126
141
 
127
142
  t_save_start = perf_counter()
@@ -166,35 +181,49 @@ def process_video(path, *, maxsize, quality):
166
181
  new_height = int(frame.height * scale_factor)
167
182
  frame = frame.reformat(width=new_width, height=new_height)
168
183
 
169
- # Simple rotation detection and logging
184
+ # Apply EXIF rotation if present
170
185
  if frame.rotation:
171
- try:
172
- fplanes = frame.to_ndarray()
173
- # Split into Y, U, V planes of proper dimensions
174
- planes = [
175
- fplanes[: frame.height],
176
- fplanes[frame.height : frame.height + frame.height // 4].reshape(
177
- frame.height // 2, frame.width // 2
178
- ),
179
- fplanes[frame.height + frame.height // 4 :].reshape(
180
- frame.height // 2, frame.width // 2
181
- ),
182
- ]
183
- # Rotate
184
- planes = [np.rot90(p, frame.rotation // 90) for p in planes]
185
- # Restore PyAV format
186
- planes = np.hstack([p.flat for p in planes]).reshape(
187
- -1, planes[0].shape[1]
188
- )
189
- frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
190
- del planes, fplanes
191
- except Exception as e:
192
- if "not yet supported" in str(e):
193
- logger.warning(
194
- f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
186
+ # frame.rotation indicates clockwise rotation needed to display correctly
187
+ # np.rot90 rotates counter-clockwise, so we negate k
188
+ k = (frame.rotation // 90) % 4 # Convert to counter-clockwise rotations
189
+ if k == 2:
190
+ # 180° rotation can be done in YUV420p, preserving HDR
191
+ try:
192
+ fplanes = frame.to_ndarray()
193
+ # Split into Y, U, V planes of proper dimensions
194
+ planes = [
195
+ fplanes[: frame.height],
196
+ fplanes[
197
+ frame.height : frame.height + frame.height // 4
198
+ ].reshape(frame.height // 2, frame.width // 2),
199
+ fplanes[frame.height + frame.height // 4 :].reshape(
200
+ frame.height // 2, frame.width // 2
201
+ ),
202
+ ]
203
+ # Rotate each plane by 180°
204
+ planes = [np.rot90(p, 2) for p in planes]
205
+ # Restore PyAV format
206
+ planes = np.hstack([p.flat for p in planes]).reshape(
207
+ -1, planes[0].shape[1]
208
+ )
209
+ frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
210
+ del planes, fplanes
211
+ except Exception as e:
212
+ logger.exception(f"Error rotating video frame by 180°: {e}")
213
+ elif k in (1, 3):
214
+ # 90° or 270° rotation requires RGB conversion (loses HDR)
215
+ try:
216
+ rgb = frame.to_ndarray(format="rgb24")
217
+ rgb = np.rot90(rgb, k)
218
+ frame = av.VideoFrame.from_ndarray(rgb, format="rgb24")
219
+ frame = frame.reformat(
220
+ format="yuv420p"
221
+ ) # Convert back for encoding
222
+ del rgb
223
+ except Exception as e:
224
+ logger.exception(
225
+ f"Error rotating video frame by {frame.rotation}°: {e}"
195
226
  )
196
- else:
197
- logger.exception(f"Error rotating video frame: {e}")
198
227
  t_load_end = perf_counter()
199
228
 
200
229
  t_save_start = perf_counter()
@@ -211,6 +240,7 @@ def process_video(path, *, maxsize, quality):
211
240
  assert isinstance(ostream, av.VideoStream)
212
241
  ostream.width = frame.width
213
242
  ostream.height = frame.height
243
+ ostream.pix_fmt = frame.format.name
214
244
  icc = istream.codec_context
215
245
  occ = ostream.codec_context
216
246
 
cista/sso.py ADDED
@@ -0,0 +1,324 @@
1
+ """SSO (paskia) authentication proxy and validation module.
2
+
3
+ When paskia mode is enabled (PASKIA_BACKEND_URL is set):
4
+ - Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
5
+ - All /auth/* requests are proxied to the paskia backend
6
+
7
+ Environment variables:
8
+ PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
9
+ Must include scheme (http/https), no trailing slash
10
+ """
11
+
12
+ import asyncio
13
+ import os
14
+ import re
15
+
16
+ import httpx
17
+ import websockets
18
+ from sanic import Blueprint
19
+ from sanic.exceptions import Forbidden, SanicException, Unauthorized
20
+ from sanic.log import logger
21
+
22
+ # Auth backend URL for SSO validation (from env, no trailing slash)
23
+ _raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
24
+
25
+ # Validate and set PASKIA_BACKEND_URL
26
+ if _raw_url:
27
+ if not re.match(r"^https?://[^\s/]+$", _raw_url):
28
+ raise ValueError(
29
+ f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
30
+ "must be http(s)://host[:port] with no path or trailing slash"
31
+ )
32
+ PASKIA_BACKEND_URL = _raw_url
33
+ else:
34
+ PASKIA_BACKEND_URL = ""
35
+
36
+
37
+ def paskia_enabled() -> bool:
38
+ """Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
39
+ return bool(PASKIA_BACKEND_URL)
40
+
41
+
42
+ # Shared httpx client for SSO requests (reused for connection pooling)
43
+ _client: httpx.AsyncClient | None = None
44
+
45
+
46
+ async def get_client() -> httpx.AsyncClient:
47
+ """Get or create the shared httpx client."""
48
+ global _client
49
+ if _client is None or _client.is_closed:
50
+ _client = httpx.AsyncClient(timeout=1.0)
51
+ return _client
52
+
53
+
54
+ async def close_client():
55
+ """Close the shared httpx client."""
56
+ global _client
57
+ if _client is not None and not _client.is_closed:
58
+ await _client.aclose()
59
+ _client = None
60
+
61
+
62
+ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
63
+ """Validate an SSO request against the auth backend.
64
+
65
+ Args:
66
+ request: The Sanic request object
67
+ perm: Permission to validate (default: cista:login, privileged also cista:admin)
68
+
69
+ Returns:
70
+ User info dict if valid, None if validation fails with auth required response
71
+
72
+ Raises:
73
+ Forbidden: If access is denied (403)
74
+ Unauthorized: If authentication is required (401)
75
+ """
76
+ if not paskia_enabled():
77
+ return None
78
+
79
+ client = await get_client()
80
+
81
+ headers = {}
82
+ if "host" in request.headers:
83
+ headers["host"] = request.headers["host"]
84
+ if "cookie" in request.headers:
85
+ headers["cookie"] = request.headers["cookie"]
86
+ if "authorization" in request.headers:
87
+ headers["authorization"] = request.headers["authorization"]
88
+ headers["accept"] = "application/json"
89
+ headers["x-forwarded-for"] = request.client_ip
90
+ headers["x-forwarded-host"] = request.host
91
+ headers["x-forwarded-proto"] = request.scheme
92
+
93
+ url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
94
+
95
+ try:
96
+ response = await client.post(
97
+ url,
98
+ headers=headers,
99
+ )
100
+
101
+ if response.status_code == 200:
102
+ try:
103
+ data = response.json()
104
+ request.ctx.sso_user = data
105
+ if "set-cookie" in response.headers:
106
+ request.ctx.sso_cookies = response.headers.get_list("set-cookie")
107
+ return data
108
+ except Exception:
109
+ request.ctx.sso_user = {}
110
+ return {}
111
+
112
+ try:
113
+ error_data = response.json()
114
+ except Exception:
115
+ error_data = {"detail": response.text or "Authentication error"}
116
+
117
+ if response.status_code == 401:
118
+ if "auth" in error_data and "iframe" in error_data["auth"]:
119
+ error_data["auth"]["iframe"] += "&theme=light"
120
+ raise Unauthorized(
121
+ error_data.get("detail", "Authentication required"),
122
+ "cookie",
123
+ context=error_data,
124
+ quiet=True,
125
+ )
126
+ elif response.status_code == 403:
127
+ raise Forbidden(
128
+ error_data.get("detail", "Access denied"),
129
+ context=error_data,
130
+ quiet=True,
131
+ )
132
+ else:
133
+ detail = error_data.get("detail", "")
134
+ logger.warning(
135
+ f"SSO validation {url} returned {response.status_code}: {detail}"
136
+ )
137
+ raise Forbidden(
138
+ detail or "Authentication error",
139
+ context=error_data,
140
+ quiet=True,
141
+ )
142
+
143
+ except httpx.RequestError as e:
144
+ logger.error(f"SSO validation {url} network error: {e}")
145
+ raise SanicException(
146
+ "Authentication service unavailable",
147
+ status_code=502,
148
+ quiet=True,
149
+ )
150
+
151
+
152
+ async def proxy_auth_request(request):
153
+ """Proxy a request to the auth backend.
154
+
155
+ All requests under /auth/ are proxied when paskia mode is enabled.
156
+ """
157
+ client = await get_client()
158
+
159
+ path = request.path
160
+ query_string = request.query_string
161
+ url = f"{PASKIA_BACKEND_URL}{path}"
162
+ if query_string:
163
+ url = f"{url}?{query_string}"
164
+
165
+ skip_headers = {
166
+ "connection",
167
+ "keep-alive",
168
+ "transfer-encoding",
169
+ "te",
170
+ "trailer",
171
+ "upgrade",
172
+ "proxy-authorization",
173
+ "proxy-authenticate",
174
+ "forwarded",
175
+ "x-forwarded-for",
176
+ "x-forwarded-host",
177
+ "x-forwarded-proto",
178
+ }
179
+
180
+ headers = [
181
+ (key, value)
182
+ for key, value in request.headers.items()
183
+ if key.lower() not in skip_headers
184
+ ]
185
+ headers.append(("x-forwarded-for", request.client_ip))
186
+ headers.append(("x-forwarded-host", request.host))
187
+ headers.append(("x-forwarded-proto", request.scheme))
188
+
189
+ try:
190
+ async with client.stream(
191
+ method=request.method,
192
+ url=url,
193
+ headers=headers,
194
+ content=request.body if request.body else None,
195
+ ) as response:
196
+ raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
197
+
198
+ resp_hop_by_hop = {
199
+ "connection",
200
+ "keep-alive",
201
+ "transfer-encoding",
202
+ "te",
203
+ "trailer",
204
+ "upgrade",
205
+ }
206
+
207
+ resp_headers = [
208
+ (key, value)
209
+ for key, value in response.headers.multi_items()
210
+ if key.lower() not in resp_hop_by_hop
211
+ ]
212
+
213
+ from sanic import raw as raw_response
214
+
215
+ return raw_response(
216
+ raw_content,
217
+ status=response.status_code,
218
+ headers=resp_headers,
219
+ content_type=response.headers.get("content-type", "application/json"),
220
+ )
221
+
222
+ except httpx.RequestError as e:
223
+ logger.error(f"Auth proxy request failed: {e}")
224
+ from sanic import json
225
+
226
+ return json(
227
+ {"detail": "Authentication service unavailable", "error": str(e)},
228
+ status=503,
229
+ )
230
+
231
+
232
+ async def proxy_auth_websocket(request, ws):
233
+ """Proxy a WebSocket connection to the auth backend."""
234
+ path = request.path
235
+ query_string = request.query_string
236
+ ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
237
+ "https://", "wss://"
238
+ )
239
+ url = f"{ws_backend}{path}"
240
+ if query_string:
241
+ url = f"{url}?{query_string}"
242
+
243
+ additional_headers = {}
244
+ if "cookie" in request.headers:
245
+ additional_headers["cookie"] = request.headers["cookie"]
246
+ if "authorization" in request.headers:
247
+ additional_headers["authorization"] = request.headers["authorization"]
248
+ if "origin" in request.headers:
249
+ additional_headers["origin"] = request.headers["origin"]
250
+ if "user-agent" in request.headers:
251
+ additional_headers["user-agent"] = request.headers["user-agent"]
252
+ additional_headers["x-forwarded-for"] = request.ip
253
+ additional_headers["x-forwarded-host"] = request.host
254
+ additional_headers["x-forwarded-proto"] = request.scheme
255
+
256
+ try:
257
+ async with websockets.connect(
258
+ url, additional_headers=additional_headers
259
+ ) as backend_ws:
260
+
261
+ async def forward_to_backend():
262
+ try:
263
+ async for message in ws:
264
+ await backend_ws.send(message)
265
+ except Exception:
266
+ pass
267
+
268
+ async def forward_to_client():
269
+ try:
270
+ async for message in backend_ws:
271
+ await ws.send(message)
272
+ except Exception:
273
+ pass
274
+
275
+ await asyncio.gather(
276
+ forward_to_backend(),
277
+ forward_to_client(),
278
+ return_exceptions=True,
279
+ )
280
+ except Exception as e:
281
+ logger.error(f"WebSocket proxy to {url} failed: {e}")
282
+
283
+
284
+ def _is_websocket_request(request) -> bool:
285
+ """Check if the request is a WebSocket upgrade request."""
286
+ connection = request.headers.get("connection", "").lower()
287
+ upgrade = request.headers.get("upgrade", "").lower()
288
+ connection_tokens = [t.strip() for t in connection.split(",")]
289
+ return "upgrade" in connection_tokens and upgrade == "websocket"
290
+
291
+
292
+ async def _handle_websocket_upgrade(request):
293
+ """Handle WebSocket upgrade and proxy the connection."""
294
+ protocol = request.transport.get_protocol()
295
+ ws = await protocol.websocket_handshake(request, subprotocols=None)
296
+ await proxy_auth_websocket(request, ws)
297
+
298
+
299
+ # Blueprint for auth proxy routes (only registered when paskia_enabled())
300
+ bp = Blueprint("sso", url_prefix="/auth")
301
+
302
+
303
+ @bp.route(
304
+ "/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
305
+ )
306
+ async def auth_proxy(request, path=""):
307
+ """Proxy all auth requests to the auth backend."""
308
+ if _is_websocket_request(request):
309
+ await _handle_websocket_upgrade(request)
310
+ from sanic import empty
311
+
312
+ return empty()
313
+ return await proxy_auth_request(request)
314
+
315
+
316
+ @bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
317
+ async def auth_proxy_root(request):
318
+ """Proxy root auth requests to the auth backend."""
319
+ if _is_websocket_request(request):
320
+ await _handle_websocket_upgrade(request)
321
+ from sanic import empty
322
+
323
+ return empty()
324
+ return await proxy_auth_request(request)
cista/util/apphelpers.py CHANGED
@@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e):
33
33
  logger.exception(e)
34
34
  # Non-browsers get JSON errors
35
35
  if "text/html" not in request.headers.accept:
36
+ # Include auth context if present (for SSO auth required responses)
37
+ # Auth must be at top level for paskia library to detect it
38
+ response_data = {"code": code, "message": message, "detail": message, **context}
36
39
  return jres(
37
- ErrorMsg({"code": code, "message": message, **context}),
40
+ response_data,
38
41
  status=code,
39
42
  )
40
43
  # Redirections flash the error message via cookies
@@ -52,7 +55,7 @@ def websocket_wrapper(handler):
52
55
  @wraps(handler)
53
56
  async def wrapper(request, ws, *args, **kwargs):
54
57
  try:
55
- auth.verify(request)
58
+ await auth.verify(request)
56
59
  await handler(request, ws, *args, **kwargs)
57
60
  except Exception as e:
58
61
  context, code, message = {}, 500, str(e) or "Internal Server Error"
cista/watching.py CHANGED
@@ -440,14 +440,14 @@ def watcher_poll(loop):
440
440
  quit.wait(0.1 + 8 * dur)
441
441
 
442
442
 
443
- def start(app, loop):
443
+ def start(app):
444
444
  global rootpath
445
445
  config.load_config()
446
446
  rootpath = config.config.path
447
447
  use_inotify = sys.platform == "linux"
448
448
  app.ctx.watcher = threading.Thread(
449
449
  target=watcher_inotify if use_inotify else watcher_poll,
450
- args=[loop],
450
+ args=[app.loop],
451
451
  # Descriptive name for system monitoring
452
452
  name=f"cista-watcher {rootpath}",
453
453
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cista
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Dropbox-like file server with modern web interface
5
5
  Project-URL: Homepage, https://git.zi.fi/Vasanko/cista-storage
6
6
  Author: Vasanko
@@ -17,6 +17,10 @@ Requires-Dist: argon2-cffi>=25.1.0
17
17
  Requires-Dist: av>=15.0.0
18
18
  Requires-Dist: blake3>=1.0.5
19
19
  Requires-Dist: docopt>=0.6.2
20
+ Requires-Dist: fastapi-vue>=0.5.1
21
+ Requires-Dist: fastapi[standard]>=0.128.0
22
+ Requires-Dist: html5tagger>=1.3.0
23
+ Requires-Dist: httpx>=0.28.0
20
24
  Requires-Dist: inotify>=0.2.12
21
25
  Requires-Dist: msgspec>=0.19.0
22
26
  Requires-Dist: natsort>=8.4.0
@@ -26,7 +30,7 @@ Requires-Dist: pillow-heif>=1.1.0
26
30
  Requires-Dist: pillow>=11.3.0
27
31
  Requires-Dist: pyjwt>=2.10.1
28
32
  Requires-Dist: pymupdf>=1.26.3
29
- Requires-Dist: sanic>=25.3.0
33
+ Requires-Dist: sanic>=25.12.0
30
34
  Requires-Dist: setproctitle>=1.3.6
31
35
  Requires-Dist: stream-zip>=0.0.83
32
36
  Requires-Dist: tomli-w>=1.2.0
@@ -0,0 +1,32 @@
1
+ cista/__init__.py,sha256=4IT57gQUTOV6q9zFGj9i6E9LmiRz3dIfHK3q4qCeOIc,66
2
+ cista/__main__.py,sha256=pQIon1qf2qs99CJj30YQU5Ho2PLJ16jUI9u8tKSmfQs,6093
3
+ cista/_version.py,sha256=wbFJCZWBQ32fgy3Okdo1czzO3kMyogcw-QyfGnUk8gk,77
4
+ cista/api.py,sha256=pBa2NoWSlJ_XYeD4_X6WUi7n7n7UgCLJGgEEe6dsDK0,5291
5
+ cista/app.py,sha256=cQ5eCezvJ3kSlGa73bKEIqaWfueS1lLYBZUz_vOvKPU,10329
6
+ cista/auth.py,sha256=UATotNVnH0KxJW91X1IL_nvwC25jONhNTmfg0iFqMLI,14531
7
+ cista/config.py,sha256=noKbZjpcEq5WG55WEkuKKudBzb5InoGEDtSWeUdVrJM,6183
8
+ cista/droppy.py,sha256=04a7TyZjmgmaLifGyH3WMAbwCAGBGPEl9oFYc423dQA,1304
9
+ cista/fileio.py,sha256=A8OBsv_jN4IgHaZCr8s9t6uqASu3r6qxPCILPKVxXQ0,2939
10
+ cista/preview.py,sha256=hZwXLVhxX3KLziyzgQLvL1cXdn1XYWTpZdWXBsP8fw8,9551
11
+ cista/protocol.py,sha256=GBLUwg7kjvCG8tUyIXmIN3rMkA4GbQNm9yAB8aatO_c,3226
12
+ cista/serve.py,sha256=PP55WfKlsuuBYnLtK-Ne5p85w4gVGcvYp9BoKP9UHUg,1931
13
+ cista/server80.py,sha256=opjtARyheVXQfJ5wBOMyzGDAtpMHnOpKNoZ1EsIytnI,780
14
+ cista/session.py,sha256=zc-A0iPaepAOLIoO5PTAHy9qn2NZb2zQir3Rm4ZmaK0,1090
15
+ cista/sso.py,sha256=z3ELX7Griv6W_OQuZA7y-u0rjk9ljHCUjrCEV-zEYE0,10410
16
+ cista/watching.py,sha256=zo1Q1jBXPSjRK8z4pay3ktsphOYcL795AhJWUXP2F7o,14314
17
+ cista/frontend-build/index.html,sha256=JbGtjN5HQjqaFTOfHp769y0gLYpXDC9cita_4SZnnyA,725
18
+ cista/frontend-build/robots.txt,sha256=Mx6pCQ2wyfb1l72YQP1bFxgw9uCzuhyyTfqR8Mla7cE,26
19
+ cista/frontend-build/assets/icons-DMD182WZ.js,sha256=-BkAWEpc8diE9goJhpYHUOopFwHQ9Rzx1-OvQkPwOfk,102556
20
+ cista/frontend-build/assets/index-D4WrmH4f.js,sha256=M2rwuSubkD_D8vI_2K8h0eZgL-UyYPJuNw7ZxHK2-J4,117507
21
+ cista/frontend-build/assets/index-DQ2iYrs-.css,sha256=LYsPV1tuP_4H_noma9HBqHSMq4kSP-w49Q69tO7oL1Y,33100
22
+ cista/frontend-build/assets/logo-ctv8tVwU.svg,sha256=l9HX62cauXDeo8zXU5-xNWhLmm-y0-xsXp_1xzHQX7s,258
23
+ cista/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ cista/util/apphelpers.py,sha256=sQ3oQGa3XlgPIIKeYDgk6wzmtml-O3GZH7g3lsePUXY,2603
25
+ cista/util/asynclink.py,sha256=3jl57o6f2AQGYDKV4hJFHSJUuJaKy3-dBctxNut8_9E,3122
26
+ cista/util/filename.py,sha256=_0qO7mPudritrSnlHQTAh03KiunYrt3I9vat5ZVXXEg,581
27
+ cista/util/lrucache.py,sha256=Gpzx7FNtN1Xkdlgipa1oGebnWolMJVZfruW3KKlX7Ms,2070
28
+ cista/util/pwgen.py,sha256=yapMYCAzOGqIm51LsQoBbkXAQQufAhbX0hOI1fPQ8nQ,6276
29
+ cista-1.4.0.dist-info/METADATA,sha256=Y3BxytWAAEhCUeKZBpV_Vv9Bt1heqCHCJMUSvUWad2Q,7057
30
+ cista-1.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
+ cista-1.4.0.dist-info/entry_points.txt,sha256=_XK4tjdkpyX8yUrVMHboZ5EJ9Q1bNqhuIkKoA06WiKw,46
32
+ cista-1.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1 +0,0 @@
1
- import{o as t,c as o,a as r}from"./index-5b083aad.js";const n={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 28 28"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"M19.2 2.6H6.1V29h19.8V9.3zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16z"},null,-1)]))}const d={render:s};export{d as default,s as render};
@@ -1 +0,0 @@
1
- import{o,c as t,a as r}from"./index-5b083aad.js";const l={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(s,e){return o(),t("svg",l,e[0]||(e[0]=[r("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5z"},null,-1)]))}const d={render:n};export{d as default,n as render};
@@ -1 +0,0 @@
1
- import{o as t,c as o,a as r}from"./index-5b083aad.js";const s={xmlns:"http://www.w3.org/2000/svg",width:"640",height:"640",viewBox:"0 -32 640 640"};function a(n,e){return t(),o("svg",s,e[0]||(e[0]=[r("path",{d:"M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72"},null,-1)]))}const l={render:a};export{l as default,a as render};
@@ -1 +0,0 @@
1
- import{o,c as t,a as r}from"./index-5b083aad.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-6 -2 44 36"};function n(a,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -1 +0,0 @@
1
- import{o,c as t,a as r}from"./index-5b083aad.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -6 16 44"};function n(a,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -1 +0,0 @@
1
- import{o as t,c as o,a as r}from"./index-5b083aad.js";const n={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-48 0 512 512"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"M320 96 128 288l-64-64-64 64 128 128 256-256z"},null,-1)]))}const l={render:s};export{l as default,s as render};
@@ -1 +0,0 @@
1
- import{o as t,c as o,a as r}from"./index-5b083aad.js";const n={xmlns:"http://www.w3.org/2000/svg",width:"512",height:"512",viewBox:"-24 8 512 512"};function s(a,e){return t(),o("svg",n,e[0]||(e[0]=[r("path",{d:"m304 96-48 48 112 112-112 112 48 48 144-160zm-160 0L0 256l144 160 48-48L80 256l112-112z"},null,-1)]))}const c={render:s};export{c as default,s as render};
@@ -1 +0,0 @@
1
- import{o,c as t,a as r}from"./index-5b083aad.js";const n={xmlns:"http://www.w3.org/2000/svg",viewBox:"-2 -2 36 36"};function s(h,e){return o(),t("svg",n,e[0]||(e[0]=[r("path",{d:"M26 8h-6V6l-6-6H0v24h12v8h20V14zm0 2.83L29.17 14H26zm-12-8L17.17 6H14zM2 2h10v6h6v14H2zm28 28H14v-6h6V10h4v6h6z"},null,-1)]))}const v={render:s};export{v as default,s as render};
@@ -1 +0,0 @@
1
- import{o as c,c as t,a as o}from"./index-5b083aad.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(s,e){return c(),t("svg",r,e[0]||(e[0]=[o("path",{d:"M19.2 2.6H6.1V29h19.8V9.3zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4z"},null,-1)]))}const l={render:n};export{l as default,n as render};
@@ -1 +0,0 @@
1
- import{o as c,c as t,a as o}from"./index-5b083aad.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function l(n,e){return c(),t("svg",r,e[0]||(e[0]=[o("path",{d:"M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5z"},null,-1)]))}const a={render:l};export{a as default,l as render};
@@ -1 +0,0 @@
1
- import{o,c as t,a as r}from"./index-5b083aad.js";const s={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function n(l,e){return o(),t("svg",s,e[0]||(e[0]=[r("path",{d:"M25.3 8.56 17.88 16l7.44 7.44-1.86 1.87L16 17.9l-7.44 7.4-1.86-1.85L14.12 16 6.68 8.56 8.55 6.7 16 14.12l7.44-7.44z"},null,-1)]))}const c={render:n};export{c as default,n as render};
@@ -1 +0,0 @@
1
- import{o as t,c as o,a}from"./index-5b083aad.js";const r={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 32 32"};function s(n,e){return t(),o("svg",r,e[0]||(e[0]=[a("path",{d:"M24.27 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v19.2a3.2 3.2 0 0 0 3.2 3.2h19.2a3.2 3.2 0 0 0 3.2-3.2V8.2zm-1.87 9.6c0 .88-.72 1.6-1.6 1.6h-9.6a1.6 1.6 0 0 1-1.6-1.6v-8h12.8zm-1.6-6.4h-3.2v6.4h3.2z"},null,-1)]))}const d={render:s};export{d as default,s as render};
@@ -1 +0,0 @@
1
- import{o as s,c as e,a as o}from"./index-5b083aad.js";const t={xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30"};function l(n,c){return s(),e("svg",t,c[0]||(c[0]=[o("path",{d:"M23 25.9c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3-.6.1-.8.3-.3.5-.3.8.1.6.3.8.5.3.8.3.6-.1.8-.3c.2-.3.3-.5.3-.8m4.6 0c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3-.6.1-.8.3-.3.5-.3.8.1.6.3.8.5.3.8.3.6-.1.8-.3c.2-.3.3-.5.3-.8m2.3-4v5.7c0 .5-.2.9-.5 1.2s-.7.5-1.2.5H1.9c-.5 0-.9-.2-1.2-.5s-.5-.7-.5-1.2v-5.7c0-.5.2-.9.5-1.2s.7-.5 1.2-.5h8.3l2.4 2.4c.7.7 1.5 1 2.4 1s1.7-.3 2.4-1l2.4-2.4h8.3c.5 0 .9.2 1.2.5q.6.45.6 1.2m-5.8-10.2c.2.5.1.9-.3 1.3l-8 8c-.2.2-.5.3-.8.3s-.6-.1-.8-.3l-8-8c-.4-.3-.5-.8-.3-1.3S6.5 11 7 11h4.6V3c0-.3.1-.6.3-.8s.5-.3.8-.3h4.6c.3 0 .6.1.8.3s.3.5.3.8v8H23c.5 0 .8.2 1.1.7"},null,-1)]))}const a={render:l};export{a as default,l as render};
@@ -1 +0,0 @@
1
- import{o as t,c as o,a as n}from"./index-5b083aad.js";const r={xmlns:"http://www.w3.org/2000/svg",width:"448",height:"448",viewBox:"-136 0 448 448"};function a(s,e){return t(),o("svg",r,e[0]||(e[0]=[n("path",{d:"M128 312v56q0 6.5-4.75 11.25T112 384H48q-6.5 0-11.25-4.75T32 368v-56q0-6.5 4.75-11.25T48 296h64q6.5 0 11.25 4.75T128 312m7.5-264-7 192q-.25 6.5-5.13 11.25T112 256H48q-6.5 0-11.38-4.75T31.5 240l-7-192q-.25-6.5 4.38-11.25T40 32h80q6.5 0 11.13 4.75T135.5 48"},null,-1)]))}const c={render:a};export{c as default,a as render};