blob-viewer 0.1.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 (65) hide show
  1. blob_viewer/__init__.py +17 -0
  2. blob_viewer/api.py +469 -0
  3. blob_viewer/auth.py +115 -0
  4. blob_viewer/cache.py +218 -0
  5. blob_viewer/cli.py +723 -0
  6. blob_viewer/db.py +143 -0
  7. blob_viewer/desktop.py +92 -0
  8. blob_viewer/paths.py +75 -0
  9. blob_viewer/preview.py +106 -0
  10. blob_viewer/secrets.py +79 -0
  11. blob_viewer/server.py +93 -0
  12. blob_viewer/service.py +97 -0
  13. blob_viewer/static/css/base.css +489 -0
  14. blob_viewer/static/css/themes.css +214 -0
  15. blob_viewer/static/favicon.svg +5 -0
  16. blob_viewer/static/js/api.js +71 -0
  17. blob_viewer/static/js/app.js +1026 -0
  18. blob_viewer/static/js/icons.js +65 -0
  19. blob_viewer/static/js/ui.js +126 -0
  20. blob_viewer/static/vendor/highjs/github-dark.min.css +10 -0
  21. blob_viewer/static/vendor/highjs/github.min.css +10 -0
  22. blob_viewer/static/vendor/highjs/highlight.min.js +1244 -0
  23. blob_viewer/static/vendor/highjs/languages/bash.min.js +21 -0
  24. blob_viewer/static/vendor/highjs/languages/c.min.js +41 -0
  25. blob_viewer/static/vendor/highjs/languages/cpp.min.js +47 -0
  26. blob_viewer/static/vendor/highjs/languages/csharp.min.js +49 -0
  27. blob_viewer/static/vendor/highjs/languages/css.min.js +31 -0
  28. blob_viewer/static/vendor/highjs/languages/dockerfile.min.js +8 -0
  29. blob_viewer/static/vendor/highjs/languages/go.min.js +20 -0
  30. blob_viewer/static/vendor/highjs/languages/graphql.min.js +12 -0
  31. blob_viewer/static/vendor/highjs/languages/ini.min.js +15 -0
  32. blob_viewer/static/vendor/highjs/languages/java.min.js +38 -0
  33. blob_viewer/static/vendor/highjs/languages/javascript.min.js +81 -0
  34. blob_viewer/static/vendor/highjs/languages/json.min.js +8 -0
  35. blob_viewer/static/vendor/highjs/languages/kotlin.min.js +46 -0
  36. blob_viewer/static/vendor/highjs/languages/lua.min.js +15 -0
  37. blob_viewer/static/vendor/highjs/languages/markdown.min.js +32 -0
  38. blob_viewer/static/vendor/highjs/languages/perl.min.js +41 -0
  39. blob_viewer/static/vendor/highjs/languages/php.min.js +59 -0
  40. blob_viewer/static/vendor/highjs/languages/protobuf.min.js +11 -0
  41. blob_viewer/static/vendor/highjs/languages/python.min.js +42 -0
  42. blob_viewer/static/vendor/highjs/languages/ruby.min.js +54 -0
  43. blob_viewer/static/vendor/highjs/languages/rust.min.js +27 -0
  44. blob_viewer/static/vendor/highjs/languages/sql.min.js +21 -0
  45. blob_viewer/static/vendor/highjs/languages/swift.min.js +68 -0
  46. blob_viewer/static/vendor/highjs/languages/typescript.min.js +99 -0
  47. blob_viewer/static/vendor/highjs/languages/xml.min.js +29 -0
  48. blob_viewer/static/vendor/highjs/languages/yaml.min.js +28 -0
  49. blob_viewer/static/vendor/marked.min.js +69 -0
  50. blob_viewer/static/vendor/pdfjs/pdf.min.mjs +21 -0
  51. blob_viewer/static/vendor/pdfjs/pdf.worker.min.mjs +21 -0
  52. blob_viewer/static/vendor/purify.min.js +3 -0
  53. blob_viewer/storage/__init__.py +13 -0
  54. blob_viewer/storage/base.py +73 -0
  55. blob_viewer/storage/factory.py +26 -0
  56. blob_viewer/storage/local.py +152 -0
  57. blob_viewer/storage/s3.py +191 -0
  58. blob_viewer/templates/index.html +35 -0
  59. blob_viewer/themes.py +31 -0
  60. blob_viewer/workspaces.py +299 -0
  61. blob_viewer-0.1.0.dist-info/METADATA +206 -0
  62. blob_viewer-0.1.0.dist-info/RECORD +65 -0
  63. blob_viewer-0.1.0.dist-info/WHEEL +4 -0
  64. blob_viewer-0.1.0.dist-info/entry_points.txt +2 -0
  65. blob_viewer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,17 @@
1
+ """Blob Viewer — a personal S3/MinIO bucket browser and file previewer.
2
+
3
+ Blob Viewer lets you register many *workspaces* (S3-compatible buckets or local
4
+ folders), navigate their contents with a modern web UI (MinIO / OneDrive style),
5
+ preview almost any file type and keep a local download cache so files are only
6
+ fetched once.
7
+ """
8
+
9
+ __title__ = "blob-viewer"
10
+ __version__ = "0.1.0"
11
+ __author__ = "Jason Jiménez Cruz"
12
+ __license__ = "MIT"
13
+ __app_name__ = "Blob Viewer"
14
+ __url__ = "https://github.com/jasonjimnz/blob-viewer"
15
+
16
+ APP_NAME = __app_name__
17
+ APP_USER_AGENT = f"blob-viewer/{__version__}"
blob_viewer/api.py ADDED
@@ -0,0 +1,469 @@
1
+ """HTTP API for Blob Viewer (Flask).
2
+
3
+ JSON REST API under ``/api/`` plus the single-page UI at ``/``. When the app
4
+ runs in locked mode every API call requires a valid session cookie, obtained
5
+ from ``POST /api/auth/login``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from flask import Flask, Response, g, jsonify, render_template, request, send_file
14
+ from werkzeug.exceptions import BadRequest
15
+
16
+ from . import __version__
17
+ from .auth import AuthError
18
+ from .cache import CacheError
19
+ from .preview import category_for, hljs_language, mime_for
20
+ from .service import AppService
21
+ from .storage import NotFoundError, ReadOnlyError, StorageError, factory
22
+ from .workspaces import REDACTED_SENTINEL, WorkspaceError, WorkspaceNotFound
23
+
24
+ SESSION_COOKIE = "bv_session"
25
+
26
+
27
+ class ApiError(Exception):
28
+ def __init__(self, message: str, status: int = 400) -> None:
29
+ super().__init__(message)
30
+ self.message = message
31
+ self.status = status
32
+
33
+
34
+ def create_app(service: AppService) -> Flask:
35
+ app = Flask(
36
+ "blob-viewer",
37
+ static_folder=Path(__file__).parent / "static",
38
+ static_url_path="/static",
39
+ template_folder=Path(__file__).parent / "templates",
40
+ )
41
+ app.config["JSON_SORT_KEYS"] = False
42
+ app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 * 1024 # 512 MB uploads
43
+ app.json.ensure_ascii = False
44
+
45
+ # -- auth gate ------------------------------------------------------------
46
+
47
+ @app.before_request
48
+ def _auth_gate() -> Response | None:
49
+ if not request.path.startswith("/api/"):
50
+ return None
51
+ if request.path in ("/api/health",) or request.path == "/api/session":
52
+ return None
53
+ if request.path == "/api/auth/login" and request.method == "POST":
54
+ return None
55
+ if not service.locked:
56
+ return None
57
+ token = request.cookies.get(SESSION_COOKIE)
58
+ username = service.auth.validate_session(token)
59
+ if username is None:
60
+ return jsonify(error="Authentication required", code="auth_required"), 401
61
+ g.username = username
62
+ return None
63
+
64
+ # -- error handling ---------------------------------------------------------
65
+
66
+ def _bad_request(exc: Exception) -> tuple[Response, int]:
67
+ return jsonify(error=str(exc) or exc.__class__.__name__), 400
68
+
69
+ @app.errorhandler(ApiError)
70
+ def _api_error(exc: ApiError) -> tuple[Response, int]:
71
+ return jsonify(error=exc.message), exc.status
72
+
73
+ @app.errorhandler(WorkspaceNotFound)
74
+ def _ws_not_found(exc: WorkspaceNotFound) -> tuple[Response, int]:
75
+ return jsonify(error=str(exc)), 404
76
+
77
+ @app.errorhandler(NotFoundError)
78
+ def _not_found(exc: NotFoundError) -> tuple[Response, int]:
79
+ return jsonify(error=str(exc)), 404
80
+
81
+ @app.errorhandler(ReadOnlyError)
82
+ def _read_only(exc: ReadOnlyError) -> tuple[Response, int]:
83
+ return jsonify(error=str(exc), code="read_only"), 403
84
+
85
+ for exc_class in (StorageError, WorkspaceError, CacheError, AuthError, BadRequest, ValueError, KeyError):
86
+ app.errorhandler(exc_class)(_bad_request)
87
+
88
+ @app.errorhandler(500)
89
+ def _server_error(exc: Exception) -> tuple[Response, int]:
90
+ return jsonify(error="Internal server error", detail=str(exc)), 500
91
+
92
+ # -- helpers ----------------------------------------------------------------
93
+
94
+ def require_workspace(workspace_id: str):
95
+ return service.workspaces.get(workspace_id)
96
+
97
+ def backend_for(workspace):
98
+ return factory.storage_for(workspace)
99
+
100
+ # -- session / auth -----------------------------------------------------------
101
+
102
+ @app.get("/api/health")
103
+ def health():
104
+ return jsonify(status="ok", version=__version__, locked=service.locked)
105
+
106
+ @app.get("/api/session")
107
+ def session_info():
108
+ token = request.cookies.get(SESSION_COOKIE)
109
+ username = service.auth.validate_session(token) if service.locked else None
110
+ return jsonify(
111
+ version=__version__,
112
+ locked=service.locked,
113
+ authenticated=(not service.locked) or username is not None,
114
+ username=username,
115
+ needs_setup=service.locked and not service.auth.has_user(),
116
+ )
117
+
118
+ @app.post("/api/auth/login")
119
+ def login():
120
+ if not service.locked:
121
+ return jsonify(ok=True, message="Authentication is disabled (not locked)")
122
+ body = json_body()
123
+ if service.auth.has_user() is False:
124
+ # first run in locked mode: bootstrap the master admin
125
+ username = (body.get("username") or "").strip()
126
+ password = body.get("password") or ""
127
+ confirm = body.get("confirm_password") or ""
128
+ if confirm and confirm != password:
129
+ raise ApiError("Passwords do not match", 400)
130
+ try:
131
+ service.auth.create_user(username, password)
132
+ except AuthError as exc:
133
+ raise ApiError(str(exc), 400) from exc
134
+ token = service.auth.login(username, password)
135
+ resp = jsonify(ok=True, created=True)
136
+ _set_session_cookie(resp, token)
137
+ return resp
138
+ token = service.auth.login(body.get("username") or "", body.get("password") or "")
139
+ resp = jsonify(ok=True)
140
+ _set_session_cookie(resp, token)
141
+ return resp
142
+
143
+ @app.post("/api/auth/logout")
144
+ def logout():
145
+ service.auth.logout(request.cookies.get(SESSION_COOKIE))
146
+ resp = jsonify(ok=True)
147
+ resp.delete_cookie(SESSION_COOKIE)
148
+ return resp
149
+
150
+ # -- workspaces ---------------------------------------------------------------
151
+
152
+ @app.get("/api/workspaces")
153
+ def list_workspaces():
154
+ return jsonify(workspaces=[ws.to_api() for ws in service.workspaces.list()])
155
+
156
+ @app.post("/api/workspaces")
157
+ def create_workspace():
158
+ ws = service.workspaces.create(json_body())
159
+ return jsonify(workspace=ws.to_api()), 201
160
+
161
+ @app.get("/api/workspaces/<workspace_id>")
162
+ def get_workspace(workspace_id: str):
163
+ return jsonify(workspace=require_workspace(workspace_id).to_api())
164
+
165
+ @app.patch("/api/workspaces/<workspace_id>")
166
+ def update_workspace(workspace_id: str):
167
+ ws = service.workspaces.update(workspace_id, json_body())
168
+ return jsonify(workspace=ws.to_api())
169
+
170
+ @app.delete("/api/workspaces/<workspace_id>")
171
+ def delete_workspace(workspace_id: str):
172
+ require_workspace(workspace_id)
173
+ service.workspaces.delete(workspace_id)
174
+ return jsonify(ok=True)
175
+
176
+ @app.post("/api/workspaces/<workspace_id>/test")
177
+ def test_workspace(workspace_id: str):
178
+ ws = require_workspace(workspace_id)
179
+ body = json_body()
180
+ # allow testing with freshly typed credentials before saving
181
+ if body.get("secrets"):
182
+ data = ws.to_api()
183
+ data["secrets"] = {
184
+ k: v for k, v in body["secrets"].items() if v and v != REDACTED_SENTINEL
185
+ } or ws.secrets
186
+ ws = service.workspaces.update(workspace_id, {"secrets": data["secrets"]})
187
+ result = backend_for(ws).test_connection()
188
+ return jsonify(result)
189
+
190
+ # -- browsing -------------------------------------------------------------------
191
+
192
+ @app.get("/api/workspaces/<workspace_id>/objects")
193
+ def list_objects(workspace_id: str):
194
+ ws = require_workspace(workspace_id)
195
+ service.workspaces.touch(ws.id)
196
+ prefix = request.args.get("prefix", "")
197
+ backend = backend_for(ws)
198
+ files, dirs = backend.list_objects(prefix)
199
+ return jsonify(
200
+ prefix=prefix,
201
+ files=[_entry_json(f, ws.kind) for f in files],
202
+ dirs=[d.key for d in dirs],
203
+ )
204
+
205
+ @app.get("/api/workspaces/<workspace_id>/file")
206
+ def file_info(workspace_id: str):
207
+ ws = require_workspace(workspace_id)
208
+ path = _required_param("path")
209
+ backend = backend_for(ws)
210
+ entry = backend.get(path)
211
+ cached = service.cache.get_entry(ws.id, path)
212
+ return jsonify(
213
+ path=path,
214
+ entry=_entry_json(entry, ws.kind),
215
+ category=category_for(path, mime_for(path)),
216
+ language=hljs_language(path),
217
+ cache={
218
+ "cached": bool(cached and Path(cached.local_path).exists()),
219
+ "cached_at": cached.cached_at if cached else None,
220
+ "cached_etag": cached.etag if cached else None,
221
+ "sync_available": service.cache.is_stale(ws.id, path, backend),
222
+ },
223
+ )
224
+
225
+ @app.post("/api/workspaces/<workspace_id>/open")
226
+ def open_file(workspace_id: str):
227
+ """Ensure the file is in the local cache and return how to view it."""
228
+ ws = require_workspace(workspace_id)
229
+ path = _required_param("path", json=True)
230
+ backend = backend_for(ws)
231
+ entry, downloaded = service.cache.ensure_cached(ws.id, path, backend)
232
+ return jsonify(
233
+ path=path,
234
+ raw_url=f"/api/workspaces/{ws.id}/raw?path={_q(path)}",
235
+ downloaded=downloaded,
236
+ category=category_for(path, mime_for(path)),
237
+ language=hljs_language(path),
238
+ entry=_entry_json(entry, ws.kind),
239
+ )
240
+
241
+ @app.post("/api/workspaces/<workspace_id>/check-versions")
242
+ def check_versions(workspace_id: str):
243
+ ws = require_workspace(workspace_id)
244
+ paths = json_body().get("paths") or []
245
+ if not isinstance(paths, list) or len(paths) > 500:
246
+ raise ApiError("Provide 'paths' as a list of up to 500 keys")
247
+ return jsonify(versions=service.cache.check_versions(ws.id, paths, backend_for(ws)))
248
+
249
+ @app.post("/api/workspaces/<workspace_id>/sync")
250
+ def sync_file(workspace_id: str):
251
+ """Force a re-download of a file that changed upstream."""
252
+ ws = require_workspace(workspace_id)
253
+ path = _required_param("path", json=True)
254
+ service.cache.remove_entry(ws.id, path)
255
+ backend = backend_for(ws)
256
+ entry, _ = service.cache.ensure_cached(ws.id, path, backend)
257
+ return jsonify(ok=True, path=path, entry=_entry_json(entry, ws.kind))
258
+
259
+ @app.post("/api/workspaces/<workspace_id>/evict")
260
+ def evict_file(workspace_id: str):
261
+ ws = require_workspace(workspace_id)
262
+ path = _required_param("path", json=True)
263
+ service.cache.remove_entry(ws.id, path)
264
+ return jsonify(ok=True)
265
+
266
+ # -- raw content ---------------------------------------------------------------
267
+
268
+ @app.get("/api/workspaces/<workspace_id>/raw")
269
+ def raw_file(workspace_id: str):
270
+ """Serve file bytes from the cache (downloading first when needed).
271
+
272
+ Supports single HTTP Range requests so PDF, audio and video can seek.
273
+ """
274
+ ws = require_workspace(workspace_id)
275
+ path = _required_param("path")
276
+ backend = backend_for(ws)
277
+ cached, _ = service.cache.ensure_cached(ws.id, path, backend)
278
+ file_path = Path(cached.local_path)
279
+ if not file_path.exists():
280
+ raise ApiError("Cached file is missing", 500)
281
+ mime = mime_for(path)
282
+ range_header = request.headers.get("Range")
283
+ if range_header and range_header.startswith("bytes="):
284
+ response = _range_response(file_path, mime, range_header)
285
+ else:
286
+ response = send_file(file_path, mimetype=mime, conditional=True)
287
+ response.headers["Cache-Control"] = "private, max-age=0, must-revalidate"
288
+ response.headers["Content-Disposition"] = _inline_disposition(path)
289
+ return response
290
+
291
+ @app.get("/api/workspaces/<workspace_id>/download")
292
+ def download_file(workspace_id: str):
293
+ ws = require_workspace(workspace_id)
294
+ path = _required_param("path")
295
+ backend = backend_for(ws)
296
+ cached, _ = service.cache.ensure_cached(ws.id, path, backend)
297
+ return send_file(
298
+ Path(cached.local_path),
299
+ mimetype=mime_for(path),
300
+ as_attachment=True,
301
+ download_name=os.path.basename(path),
302
+ )
303
+
304
+ # -- search ----------------------------------------------------------------------
305
+
306
+ @app.get("/api/workspaces/<workspace_id>/search")
307
+ def search(workspace_id: str):
308
+ ws = require_workspace(workspace_id)
309
+ query = (request.args.get("q") or "").strip().lower()
310
+ if not query:
311
+ return jsonify(results=[])
312
+ entries = backend_for(ws).list_all(request.args.get("prefix", ""))
313
+ results = [_entry_json(e, ws.kind) for e in entries if query in e.key.lower()][:200]
314
+ return jsonify(results=results, truncated=len(entries) >= 20000)
315
+
316
+ # -- local write operations (writable local workspaces only) -----------------------
317
+
318
+ @app.put("/api/workspaces/<workspace_id>/file")
319
+ def write_file(workspace_id: str):
320
+ ws = require_workspace(workspace_id)
321
+ body = json_body()
322
+ path = body.get("path")
323
+ if not path:
324
+ raise ApiError("'path' is required")
325
+ content = body.get("content")
326
+ if not isinstance(content, str):
327
+ raise ApiError("'content' must be text")
328
+ backend = backend_for(ws)
329
+ entry = backend.write_file(path, content.encode("utf-8"))
330
+ service.cache.remove_entry(ws.id, path)
331
+ return jsonify(entry=_entry_json(entry, ws.kind))
332
+
333
+ @app.post("/api/workspaces/<workspace_id>/folder")
334
+ def create_folder(workspace_id: str):
335
+ ws = require_workspace(workspace_id)
336
+ path = json_body().get("path")
337
+ if not path:
338
+ raise ApiError("'path' is required")
339
+ backend_for(ws).create_folder(path)
340
+ return jsonify(ok=True)
341
+
342
+ @app.post("/api/workspaces/<workspace_id>/rename")
343
+ def rename(workspace_id: str):
344
+ ws = require_workspace(workspace_id)
345
+ body = json_body()
346
+ src, dst = body.get("from"), body.get("to")
347
+ if not src or not dst:
348
+ raise ApiError("'from' and 'to' are required")
349
+ backend_for(ws).rename(src, dst)
350
+ service.cache.remove_entry(ws.id, src)
351
+ return jsonify(ok=True)
352
+
353
+ @app.delete("/api/workspaces/<workspace_id>/file")
354
+ def delete_file(workspace_id: str):
355
+ ws = require_workspace(workspace_id)
356
+ path = _required_param("path")
357
+ backend_for(ws).delete(path)
358
+ service.cache.remove_entry(ws.id, path)
359
+ return jsonify(ok=True)
360
+
361
+ # -- cache & settings -----------------------------------------------------------
362
+
363
+ @app.get("/api/cache")
364
+ def cache_info():
365
+ stats = service.cache.stats()
366
+ stats["bytes_on_disk"] = service.data_dir_size()
367
+ stats["home"] = str(service.home.root)
368
+ return jsonify(stats)
369
+
370
+ @app.post("/api/cache/clear")
371
+ def cache_clear():
372
+ workspace_id = json_body().get("workspace_id")
373
+ if workspace_id:
374
+ require_workspace(workspace_id)
375
+ removed = service.cache.clear(workspace_id)
376
+ return jsonify(ok=True, removed=removed)
377
+
378
+ @app.get("/api/settings")
379
+ def get_settings():
380
+ return jsonify(settings=service.all_settings())
381
+
382
+ @app.put("/api/settings")
383
+ def put_settings():
384
+ return jsonify(settings=service.update_settings(json_body()))
385
+
386
+ # -- SPA --------------------------------------------------------------------------
387
+
388
+ @app.get("/")
389
+ @app.get("/<path:_spa_path>")
390
+ def spa(_spa_path: str = ""):
391
+ if _spa_path.startswith(("api/", "static/")):
392
+ raise ApiError("Not found", 404)
393
+ return render_template("index.html")
394
+
395
+ return app
396
+
397
+
398
+ # -- utilities -----------------------------------------------------------------------
399
+
400
+
401
+ def json_body() -> dict:
402
+ data = request.get_json(silent=True)
403
+ return data if isinstance(data, dict) else {}
404
+
405
+
406
+
407
+ def _set_session_cookie(resp: Response, token: str) -> None:
408
+ resp.set_cookie(
409
+ SESSION_COOKIE, token, httponly=True, samesite="Lax", max_age=12 * 60 * 60
410
+ )
411
+
412
+
413
+ def _required_param(name: str, *, json: bool = False) -> str:
414
+ value = (json_body() if json else request.args).get(name)
415
+ if not value:
416
+ raise ApiError(f"'{name}' is required")
417
+ return value
418
+
419
+
420
+ def _q(value: str) -> str:
421
+ from urllib.parse import quote
422
+
423
+ return quote(value, safe="")
424
+
425
+
426
+ def _entry_json(entry, kind: str) -> dict: # noqa: ANN001 - BlobEntry | CachedFile
427
+ is_dir = bool(getattr(entry, "is_dir", False))
428
+ key = entry.key
429
+ return {
430
+ "key": key,
431
+ "size": entry.size,
432
+ "last_modified": entry.last_modified,
433
+ "etag": entry.etag,
434
+ "is_dir": is_dir,
435
+ "category": None if is_dir else category_for(key),
436
+ }
437
+
438
+
439
+ def _inline_disposition(path: str) -> str:
440
+ from urllib.parse import quote
441
+
442
+ name = os.path.basename(path)
443
+ return f"inline; filename*=UTF-8''{quote(name)}"
444
+
445
+
446
+ def _range_response(file_path: Path, mime: str, range_header: str) -> Response:
447
+ """Minimal single-range support (bytes=start-end / bytes=start-)."""
448
+ size = file_path.stat().st_size
449
+ spec = range_header[6:].split(",")[0].strip()
450
+ start_str, _, end_str = spec.partition("-")
451
+ try:
452
+ if start_str:
453
+ start = int(start_str)
454
+ end = min(int(end_str), size - 1) if end_str else size - 1
455
+ else:
456
+ start = max(size - int(end_str), 0)
457
+ end = size - 1
458
+ except ValueError as exc:
459
+ raise ApiError("Invalid Range header", 416) from exc
460
+ if start > end or start >= size:
461
+ raise ApiError("Requested range not satisfiable", 416)
462
+ length = end - start + 1
463
+ with file_path.open("rb") as fh:
464
+ fh.seek(start)
465
+ data = fh.read(length)
466
+ resp = Response(data, status=206, mimetype=mime)
467
+ resp.headers["Content-Range"] = f"bytes {start}-{end}/{size}"
468
+ resp.headers["Accept-Ranges"] = "bytes"
469
+ return resp
blob_viewer/auth.py ADDED
@@ -0,0 +1,115 @@
1
+ """Authentication for locked mode.
2
+
3
+ Blob Viewer is open by default — it is a personal tool and normally binds to
4
+ ``127.0.0.1`` only. When the user starts it with the ``--locked`` flag, a
5
+ master admin account must exist (created interactively on first run) and every
6
+ API request — desktop and browser alike — requires login.
7
+
8
+ Passwords use PBKDF2-HMAC-SHA256 (210,000 iterations) from the standard
9
+ library; sessions are opaque random tokens stored server-side with an expiry.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import secrets as _secrets
17
+
18
+ from .db import Database, new_id, now
19
+ from .secrets import SecretStore
20
+
21
+ PBKDF2_ITERATIONS = 210_000
22
+ SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours
23
+
24
+
25
+ class AuthError(Exception):
26
+ pass
27
+
28
+
29
+ class AuthService:
30
+ def __init__(self, db: Database) -> None:
31
+ self.db = db
32
+
33
+ # -- users ----------------------------------------------------------------
34
+
35
+ def has_user(self) -> bool:
36
+ return self.db.query_one("SELECT 1 FROM users LIMIT 1") is not None
37
+
38
+ def create_user(self, username: str, password: str, *, force: bool = False) -> None:
39
+ username = (username or "").strip()
40
+ if not username:
41
+ raise AuthError("Username is required")
42
+ if len(password) < 8:
43
+ raise AuthError("Password must be at least 8 characters")
44
+ if self.has_user() and not force:
45
+ raise AuthError("A master admin already exists. Use 'user reset' to change it.")
46
+ salt = _secrets.token_bytes(16)
47
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
48
+ encoded = f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
49
+ with self.db.connect() as conn:
50
+ conn.execute("DELETE FROM users")
51
+ conn.execute(
52
+ "INSERT INTO users (id, username, password_hash, created_at) VALUES (?,?,?,?)",
53
+ (new_id(), username, encoded, now()),
54
+ )
55
+ conn.execute("DELETE FROM auth_sessions") # invalidate old sessions
56
+
57
+ def reset_user(self, username: str, password: str) -> None:
58
+ """Replace the master admin (requires physical access / CLI)."""
59
+ self.create_user(username, password, force=True)
60
+
61
+ def verify_credentials(self, username: str, password: str) -> bool:
62
+ row = self.db.query_one(
63
+ "SELECT password_hash FROM users WHERE username = ?", ((username or "").strip(),)
64
+ )
65
+ if row is None:
66
+ # constant-time-ish dummy check to avoid user enumeration timing
67
+ hashlib.pbkdf2_hmac("sha256", b"x", b"y", PBKDF2_ITERATIONS)
68
+ return False
69
+ try:
70
+ algorithm, iterations, salt_hex, digest_hex = row["password_hash"].split("$")
71
+ except ValueError:
72
+ return False
73
+ candidate = hashlib.pbkdf2_hmac(
74
+ "sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
75
+ )
76
+ return hmac.compare_digest(candidate.hex(), digest_hex)
77
+
78
+ def username(self) -> str | None:
79
+ row = self.db.query_one("SELECT username FROM users LIMIT 1")
80
+ return row["username"] if row else None
81
+
82
+ # -- sessions ---------------------------------------------------------------
83
+
84
+ def login(self, username: str, password: str) -> str:
85
+ """Verify credentials and return a new session token."""
86
+ if not self.verify_credentials(username, password):
87
+ raise AuthError("Invalid username or password")
88
+ token = SecretStore.new_token()
89
+ self.db.execute(
90
+ "INSERT INTO auth_sessions (token, username, created_at, expires_at) VALUES (?,?,?,?)",
91
+ (token, username.strip(), now(), now() + SESSION_TTL_SECONDS),
92
+ )
93
+ self._purge_sessions()
94
+ return token
95
+
96
+ def validate_session(self, token: str | None) -> str | None:
97
+ """Return the username for a valid, unexpired token."""
98
+ if not token:
99
+ return None
100
+ row = self.db.query_one(
101
+ "SELECT username, expires_at FROM auth_sessions WHERE token = ?", (token,)
102
+ )
103
+ if row is None:
104
+ return None
105
+ if row["expires_at"] < now():
106
+ self.db.execute("DELETE FROM auth_sessions WHERE token = ?", (token,))
107
+ return None
108
+ return row["username"]
109
+
110
+ def logout(self, token: str | None) -> None:
111
+ if token:
112
+ self.db.execute("DELETE FROM auth_sessions WHERE token = ?", (token,))
113
+
114
+ def _purge_sessions(self) -> None:
115
+ self.db.execute("DELETE FROM auth_sessions WHERE expires_at < ?", (now(),))