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.
- blob_viewer/__init__.py +17 -0
- blob_viewer/api.py +469 -0
- blob_viewer/auth.py +115 -0
- blob_viewer/cache.py +218 -0
- blob_viewer/cli.py +723 -0
- blob_viewer/db.py +143 -0
- blob_viewer/desktop.py +92 -0
- blob_viewer/paths.py +75 -0
- blob_viewer/preview.py +106 -0
- blob_viewer/secrets.py +79 -0
- blob_viewer/server.py +93 -0
- blob_viewer/service.py +97 -0
- blob_viewer/static/css/base.css +489 -0
- blob_viewer/static/css/themes.css +214 -0
- blob_viewer/static/favicon.svg +5 -0
- blob_viewer/static/js/api.js +71 -0
- blob_viewer/static/js/app.js +1026 -0
- blob_viewer/static/js/icons.js +65 -0
- blob_viewer/static/js/ui.js +126 -0
- blob_viewer/static/vendor/highjs/github-dark.min.css +10 -0
- blob_viewer/static/vendor/highjs/github.min.css +10 -0
- blob_viewer/static/vendor/highjs/highlight.min.js +1244 -0
- blob_viewer/static/vendor/highjs/languages/bash.min.js +21 -0
- blob_viewer/static/vendor/highjs/languages/c.min.js +41 -0
- blob_viewer/static/vendor/highjs/languages/cpp.min.js +47 -0
- blob_viewer/static/vendor/highjs/languages/csharp.min.js +49 -0
- blob_viewer/static/vendor/highjs/languages/css.min.js +31 -0
- blob_viewer/static/vendor/highjs/languages/dockerfile.min.js +8 -0
- blob_viewer/static/vendor/highjs/languages/go.min.js +20 -0
- blob_viewer/static/vendor/highjs/languages/graphql.min.js +12 -0
- blob_viewer/static/vendor/highjs/languages/ini.min.js +15 -0
- blob_viewer/static/vendor/highjs/languages/java.min.js +38 -0
- blob_viewer/static/vendor/highjs/languages/javascript.min.js +81 -0
- blob_viewer/static/vendor/highjs/languages/json.min.js +8 -0
- blob_viewer/static/vendor/highjs/languages/kotlin.min.js +46 -0
- blob_viewer/static/vendor/highjs/languages/lua.min.js +15 -0
- blob_viewer/static/vendor/highjs/languages/markdown.min.js +32 -0
- blob_viewer/static/vendor/highjs/languages/perl.min.js +41 -0
- blob_viewer/static/vendor/highjs/languages/php.min.js +59 -0
- blob_viewer/static/vendor/highjs/languages/protobuf.min.js +11 -0
- blob_viewer/static/vendor/highjs/languages/python.min.js +42 -0
- blob_viewer/static/vendor/highjs/languages/ruby.min.js +54 -0
- blob_viewer/static/vendor/highjs/languages/rust.min.js +27 -0
- blob_viewer/static/vendor/highjs/languages/sql.min.js +21 -0
- blob_viewer/static/vendor/highjs/languages/swift.min.js +68 -0
- blob_viewer/static/vendor/highjs/languages/typescript.min.js +99 -0
- blob_viewer/static/vendor/highjs/languages/xml.min.js +29 -0
- blob_viewer/static/vendor/highjs/languages/yaml.min.js +28 -0
- blob_viewer/static/vendor/marked.min.js +69 -0
- blob_viewer/static/vendor/pdfjs/pdf.min.mjs +21 -0
- blob_viewer/static/vendor/pdfjs/pdf.worker.min.mjs +21 -0
- blob_viewer/static/vendor/purify.min.js +3 -0
- blob_viewer/storage/__init__.py +13 -0
- blob_viewer/storage/base.py +73 -0
- blob_viewer/storage/factory.py +26 -0
- blob_viewer/storage/local.py +152 -0
- blob_viewer/storage/s3.py +191 -0
- blob_viewer/templates/index.html +35 -0
- blob_viewer/themes.py +31 -0
- blob_viewer/workspaces.py +299 -0
- blob_viewer-0.1.0.dist-info/METADATA +206 -0
- blob_viewer-0.1.0.dist-info/RECORD +65 -0
- blob_viewer-0.1.0.dist-info/WHEEL +4 -0
- blob_viewer-0.1.0.dist-info/entry_points.txt +2 -0
- blob_viewer-0.1.0.dist-info/licenses/LICENSE +21 -0
blob_viewer/__init__.py
ADDED
|
@@ -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(),))
|