@ao_zorin/zocket 1.0.0

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.
package/zocket/web.py ADDED
@@ -0,0 +1,490 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import quote_plus
9
+
10
+ from flask import Flask, jsonify, redirect, render_template, request, session, url_for
11
+
12
+ from .audit import AuditLogger
13
+ from .auth import hash_password, verify_password
14
+ from .config_store import ConfigStore
15
+ from .i18n import normalize_lang, tr
16
+ from .vault import ProjectNotFoundError, SecretNotFoundError, ValidationError, VaultService
17
+
18
+ DEFAULT_FOLDER_PICKER_ROOTS = (
19
+ "/home",
20
+ "/srv",
21
+ "/opt",
22
+ "/var/www",
23
+ "/var/lib",
24
+ )
25
+
26
+ THEME_STANDARD = "standard"
27
+ THEME_ZORIN = "zorin"
28
+ AVAILABLE_THEMES = {THEME_STANDARD, THEME_ZORIN}
29
+
30
+
31
+ def _folder_picker_roots(config: dict) -> list[Path]:
32
+ raw = config.get("folder_picker_roots")
33
+ candidates = raw if isinstance(raw, list) and raw else list(DEFAULT_FOLDER_PICKER_ROOTS)
34
+ roots: list[Path] = []
35
+ seen: set[str] = set()
36
+ for item in candidates:
37
+ if not isinstance(item, str) or not item.strip():
38
+ continue
39
+ try:
40
+ resolved = Path(item).expanduser().resolve(strict=False)
41
+ except OSError:
42
+ continue
43
+ as_str = str(resolved)
44
+ if as_str in seen:
45
+ continue
46
+ if resolved.exists() and resolved.is_dir():
47
+ roots.append(resolved)
48
+ seen.add(as_str)
49
+ return roots
50
+
51
+
52
+ def _is_subpath(child: Path, parent: Path) -> bool:
53
+ return child == parent or child.is_relative_to(parent)
54
+
55
+
56
+ def _is_allowed_path(path: Path, roots: list[Path]) -> bool:
57
+ return any(_is_subpath(path, root) for root in roots)
58
+
59
+
60
+ def _safe_resolve(path: str) -> Path:
61
+ return Path(path).expanduser().resolve(strict=False)
62
+
63
+
64
+ def _current_lang(config: dict) -> str:
65
+ if "lang" in request.args:
66
+ lang = normalize_lang(request.args.get("lang"))
67
+ session["lang"] = lang
68
+ return lang
69
+ if "lang" in session:
70
+ return normalize_lang(str(session.get("lang")))
71
+ return normalize_lang(str(config.get("language", "en")))
72
+
73
+
74
+ def normalize_variant(value: str | None) -> str:
75
+ if not value:
76
+ return "dark"
77
+ normalized = value.strip().lower()
78
+ return normalized if normalized in {"light", "dark"} else "dark"
79
+
80
+
81
+ def _current_variant(config: dict[str, Any]) -> str:
82
+ if "variant" in request.args:
83
+ variant = normalize_variant(request.args.get("variant"))
84
+ session["theme_variant"] = variant
85
+ return variant
86
+ stored = session.get("theme_variant")
87
+ if stored:
88
+ return normalize_variant(str(stored))
89
+ return normalize_variant(str(config.get("theme_variant", "dark")))
90
+
91
+
92
+ def normalize_theme(value: str | None) -> str:
93
+ if not value:
94
+ return THEME_STANDARD
95
+ normalized = value.strip().lower()
96
+ return normalized if normalized in AVAILABLE_THEMES else THEME_STANDARD
97
+
98
+
99
+ def _current_theme(config: dict[str, Any]) -> str:
100
+ arg_theme = request.args.get("theme")
101
+ if arg_theme:
102
+ theme = normalize_theme(arg_theme)
103
+ session["theme"] = theme
104
+ return theme
105
+ stored = session.get("theme")
106
+ if stored:
107
+ return normalize_theme(str(stored))
108
+ return normalize_theme(str(config.get("theme", THEME_STANDARD)))
109
+
110
+
111
+ def _is_authenticated(config: dict) -> bool:
112
+ if not bool(config.get("web_auth_enabled", True)):
113
+ return True
114
+ return bool(session.get("is_authenticated", False))
115
+
116
+
117
+ def _has_password(config: dict) -> bool:
118
+ return bool(config.get("web_password_hash")) and bool(config.get("web_password_salt"))
119
+
120
+
121
+ def create_web_app(
122
+ vault: VaultService,
123
+ cfg_store: ConfigStore,
124
+ audit: AuditLogger,
125
+ ) -> Flask:
126
+ template_dir = Path(__file__).with_name("templates").resolve()
127
+ app = Flask(__name__, template_folder=str(template_dir))
128
+ cfg = cfg_store.ensure_exists()
129
+
130
+ app.config["SECRET_KEY"] = cfg["session_secret"]
131
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
132
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
133
+ app.config["SESSION_COOKIE_SECURE"] = False
134
+
135
+ @app.context_processor
136
+ def inject_i18n():
137
+ cfg_local = cfg_store.load()
138
+ lang = _current_lang(cfg_local)
139
+ theme = _current_theme(cfg_local)
140
+ variant = _current_variant(cfg_local)
141
+ return {
142
+ "t": lambda key, **kwargs: tr(lang, key, **kwargs),
143
+ "lang": lang,
144
+ "theme": theme,
145
+ "theme_variant": variant,
146
+ }
147
+
148
+ def login_required(fn):
149
+ @wraps(fn)
150
+ def wrapper(*args, **kwargs):
151
+ cfg_local = cfg_store.load()
152
+ _current_lang(cfg_local)
153
+ if _is_authenticated(cfg_local):
154
+ return fn(*args, **kwargs)
155
+ return redirect(url_for("login", next=request.path))
156
+
157
+ return wrapper
158
+
159
+ @app.get("/login")
160
+ def login():
161
+ cfg_local = cfg_store.load()
162
+ if not bool(cfg_local.get("web_auth_enabled", True)):
163
+ return redirect("/")
164
+ if session.get("is_authenticated"):
165
+ return redirect("/")
166
+ lang = _current_lang(cfg_local)
167
+ error = request.args.get("error")
168
+ next_path = request.args.get("next") or request.path
169
+ theme = _current_theme(cfg_local)
170
+ return render_template(
171
+ "login.html",
172
+ error=error,
173
+ lang=lang,
174
+ missing_password=not _has_password(cfg_local),
175
+ next_path=next_path,
176
+ theme=theme,
177
+ )
178
+
179
+ @app.post("/login")
180
+ def login_post():
181
+ cfg_local = cfg_store.load()
182
+ if not bool(cfg_local.get("web_auth_enabled", True)):
183
+ return redirect("/")
184
+ lang = _current_lang(cfg_local)
185
+ if not _has_password(cfg_local):
186
+ return redirect("/login")
187
+ password = request.form.get("password", "")
188
+ ok = verify_password(
189
+ password=password,
190
+ salt_hex=str(cfg_local["web_password_salt"]),
191
+ expected_hash_hex=str(cfg_local["web_password_hash"]),
192
+ iterations=int(cfg_local.get("web_password_iterations", 390000)),
193
+ )
194
+ if not ok:
195
+ audit.log("web.login", "failed", "web", {"remote_addr": request.remote_addr})
196
+ return redirect(f"/login?error={quote_plus(tr(lang, 'ui.invalid_login'))}")
197
+ session["is_authenticated"] = True
198
+ audit.log("web.login", "ok", "web", {"remote_addr": request.remote_addr})
199
+ return redirect("/")
200
+
201
+ @app.post("/setup/first-run")
202
+ def first_run_setup():
203
+ cfg_local = cfg_store.load()
204
+ lang = _current_lang(cfg_local)
205
+ if _has_password(cfg_local):
206
+ return redirect("/login")
207
+
208
+ mode = (request.form.get("mode") or "").strip()
209
+ if mode == "set_password":
210
+ password = request.form.get("password", "")
211
+ password_repeat = request.form.get("password_repeat", "")
212
+ if not password:
213
+ return redirect(f"/login?error={quote_plus(tr(lang, 'ui.password_required'))}")
214
+ if password != password_repeat:
215
+ return redirect(f"/login?error={quote_plus(tr(lang, 'ui.passwords_do_not_match'))}")
216
+ salt_hex, hash_hex = hash_password(password)
217
+ cfg_local["web_password_salt"] = salt_hex
218
+ cfg_local["web_password_hash"] = hash_hex
219
+ cfg_local["web_auth_enabled"] = True
220
+ cfg_store.save(cfg_local)
221
+ session["is_authenticated"] = True
222
+ audit.log("web.setup.first_run", "ok", "web", {"mode": "set_password"})
223
+ return redirect("/")
224
+
225
+ if mode == "generate_password":
226
+ generated = secrets.token_urlsafe(24)
227
+ salt_hex, hash_hex = hash_password(generated)
228
+ cfg_local["web_password_salt"] = salt_hex
229
+ cfg_local["web_password_hash"] = hash_hex
230
+ cfg_local["web_auth_enabled"] = True
231
+ cfg_store.save(cfg_local)
232
+ session["is_authenticated"] = True
233
+ session["generated_password_once"] = generated
234
+ audit.log("web.setup.first_run", "ok", "web", {"mode": "generate_password"})
235
+ return redirect("/")
236
+
237
+ if mode == "no_password":
238
+ confirmed = request.form.get("confirm_no_password") == "1"
239
+ if not confirmed:
240
+ return redirect(
241
+ f"/login?error={quote_plus(tr(lang, 'ui.confirm_insecure_required'))}"
242
+ )
243
+ cfg_local["web_auth_enabled"] = False
244
+ cfg_local["web_password_salt"] = ""
245
+ cfg_local["web_password_hash"] = ""
246
+ cfg_store.save(cfg_local)
247
+ session["is_authenticated"] = True
248
+ audit.log("web.setup.first_run", "ok", "web", {"mode": "no_password"})
249
+ return redirect("/")
250
+
251
+ return redirect(f"/login?error={quote_plus(tr(lang, 'ui.invalid_setup_option'))}")
252
+
253
+ @app.post("/set-theme")
254
+ def set_theme():
255
+ theme = normalize_theme(request.form.get("theme"))
256
+ session["theme"] = theme
257
+ next_url = request.form.get("next") or request.referrer or "/"
258
+ return redirect(next_url)
259
+
260
+ @app.post("/set-theme-variant")
261
+ def set_theme_variant():
262
+ variant = normalize_variant(request.form.get("variant"))
263
+ session["theme_variant"] = variant
264
+ next_url = request.form.get("next") or request.referrer or "/"
265
+ return redirect(next_url)
266
+
267
+ @app.post("/logout")
268
+ def logout():
269
+ session.pop("is_authenticated", None)
270
+ return redirect("/login")
271
+
272
+ @app.get("/api/folders")
273
+ @login_required
274
+ def list_folders():
275
+ cfg_local = cfg_store.load()
276
+ roots = _folder_picker_roots(cfg_local)
277
+ if not roots:
278
+ return jsonify({"ok": False, "error": "Folder picker is not configured."}), 500
279
+
280
+ requested = (request.args.get("path") or "").strip()
281
+ if not requested:
282
+ root_rows = [{"name": str(path), "path": str(path)} for path in roots]
283
+ return jsonify(
284
+ {
285
+ "ok": True,
286
+ "current": None,
287
+ "parent": None,
288
+ "roots": root_rows,
289
+ "directories": root_rows,
290
+ }
291
+ )
292
+
293
+ try:
294
+ current = _safe_resolve(requested)
295
+ except OSError:
296
+ return jsonify({"ok": False, "error": "Invalid folder path."}), 400
297
+
298
+ if not _is_allowed_path(current, roots):
299
+ return jsonify({"ok": False, "error": "Folder is outside allowed roots."}), 403
300
+ if not current.exists():
301
+ return jsonify({"ok": False, "error": "Folder does not exist."}), 404
302
+ if not current.is_dir():
303
+ return jsonify({"ok": False, "error": "Path is not a folder."}), 400
304
+
305
+ directories: list[dict[str, str]] = []
306
+ try:
307
+ with os.scandir(current) as entries:
308
+ for entry in entries:
309
+ if entry.is_dir(follow_symlinks=False):
310
+ if entry.name.startswith("."):
311
+ continue
312
+ try:
313
+ child = _safe_resolve(entry.path)
314
+ except OSError:
315
+ continue
316
+ if _is_allowed_path(child, roots):
317
+ directories.append({"name": entry.name, "path": str(child)})
318
+ except PermissionError:
319
+ return jsonify({"ok": False, "error": "Permission denied for this folder."}), 403
320
+ directories.sort(key=lambda row: row["name"].lower())
321
+
322
+ parent = current.parent
323
+ parent_path = str(parent) if _is_allowed_path(parent, roots) and parent != current else None
324
+ return jsonify(
325
+ {
326
+ "ok": True,
327
+ "current": str(current),
328
+ "parent": parent_path,
329
+ "roots": [{"name": str(path), "path": str(path)} for path in roots],
330
+ "directories": directories,
331
+ }
332
+ )
333
+
334
+ @app.get("/")
335
+ @login_required
336
+ def index():
337
+ cfg_local = cfg_store.load()
338
+ project = request.args.get("project")
339
+ show_values = bool(int(request.args.get("show_values", "0")))
340
+ error = request.args.get("error")
341
+ lang = _current_lang(cfg_local)
342
+ generated_password = session.pop("generated_password_once", None)
343
+
344
+ projects = vault.list_projects()
345
+ selected_project = project or (projects[0]["project"] if projects else None)
346
+ selected_project_info = next(
347
+ (item for item in projects if item["project"] == selected_project),
348
+ None,
349
+ )
350
+ secrets = (
351
+ vault.list_project_secrets(
352
+ selected_project, include_values=show_values
353
+ )
354
+ if selected_project
355
+ else []
356
+ )
357
+ return render_template(
358
+ "index.html",
359
+ projects=projects,
360
+ selected_project=selected_project,
361
+ selected_project_info=selected_project_info,
362
+ secrets=secrets,
363
+ show_values=show_values,
364
+ error=error,
365
+ lang=lang,
366
+ generated_password=generated_password,
367
+ )
368
+
369
+ @app.post("/projects/create")
370
+ @login_required
371
+ def create_project():
372
+ name = request.form.get("name", "")
373
+ description = request.form.get("description", "")
374
+ folder_path = request.form.get("folder_path", "")
375
+ try:
376
+ vault.create_project(
377
+ name,
378
+ description=description,
379
+ folder_path=folder_path,
380
+ )
381
+ audit.log(
382
+ "web.project.create",
383
+ "ok",
384
+ "web",
385
+ {"project": name, "folder_path": folder_path},
386
+ )
387
+ except ValidationError as exc:
388
+ audit.log("web.project.create", "failed", "web", {"error": str(exc)})
389
+ return redirect(f"/?error={quote_plus(str(exc))}")
390
+ return redirect(f"/?project={name}")
391
+
392
+ @app.post("/projects/<project>/folder")
393
+ @login_required
394
+ def set_project_folder(project: str):
395
+ clear = request.form.get("clear") == "1"
396
+ folder_path = None if clear else request.form.get("folder_path", "")
397
+ try:
398
+ vault.set_project_folder(project, folder_path)
399
+ audit.log(
400
+ "web.project.set_folder",
401
+ "ok",
402
+ "web",
403
+ {"project": project, "cleared": clear},
404
+ )
405
+ except (ValidationError, ProjectNotFoundError) as exc:
406
+ audit.log(
407
+ "web.project.set_folder",
408
+ "failed",
409
+ "web",
410
+ {"project": project, "error": str(exc)},
411
+ )
412
+ return redirect(f"/?project={project}&error={quote_plus(str(exc))}")
413
+ return redirect(f"/?project={project}")
414
+
415
+ @app.post("/projects/<project>/secrets/upsert")
416
+ @login_required
417
+ def upsert_secret(project: str):
418
+ key = request.form.get("key", "")
419
+ value = request.form.get("value", "")
420
+ description = request.form.get("description", "")
421
+ try:
422
+ vault.upsert_secret(project=project, key=key, value=value, description=description)
423
+ audit.log("web.secret.upsert", "ok", "web", {"project": project, "key": key})
424
+ except (ValidationError, ProjectNotFoundError) as exc:
425
+ audit.log(
426
+ "web.secret.upsert",
427
+ "failed",
428
+ "web",
429
+ {"project": project, "key": key, "error": str(exc)},
430
+ )
431
+ return redirect(f"/?project={project}&error={quote_plus(str(exc))}")
432
+ return redirect(f"/?project={project}")
433
+
434
+ @app.post("/projects/<project>/secrets/<key>/delete")
435
+ @login_required
436
+ def delete_secret(project: str, key: str):
437
+ try:
438
+ vault.delete_secret(project=project, key=key)
439
+ audit.log("web.secret.delete", "ok", "web", {"project": project, "key": key})
440
+ except (ProjectNotFoundError, SecretNotFoundError) as exc:
441
+ audit.log(
442
+ "web.secret.delete",
443
+ "failed",
444
+ "web",
445
+ {"project": project, "key": key, "error": str(exc)},
446
+ )
447
+ return redirect(f"/?project={project}&error={quote_plus(str(exc))}")
448
+ return redirect(f"/?project={project}")
449
+
450
+ @app.get("/projects/<project>/secrets/<key>/value")
451
+ @login_required
452
+ def secret_value(project: str, key: str):
453
+ try:
454
+ secret = vault.get_secret(project=project, key=key)
455
+ audit.log(
456
+ "web.secret.view",
457
+ "ok",
458
+ "web",
459
+ {"project": project, "key": key},
460
+ )
461
+ return jsonify(
462
+ {
463
+ "ok": True,
464
+ "key": key,
465
+ "value": secret.get("value", ""),
466
+ "description": secret.get("description", ""),
467
+ "updated_at": secret.get("updated_at"),
468
+ }
469
+ )
470
+ except (ProjectNotFoundError, SecretNotFoundError) as exc:
471
+ audit.log(
472
+ "web.secret.view",
473
+ "failed",
474
+ "web",
475
+ {"project": project, "key": key, "error": str(exc)},
476
+ )
477
+ return jsonify({"ok": False, "error": str(exc)}), 404
478
+
479
+ @app.post("/projects/<project>/delete")
480
+ @login_required
481
+ def delete_project(project: str):
482
+ try:
483
+ vault.delete_project(project=project)
484
+ audit.log("web.project.delete", "ok", "web", {"project": project})
485
+ except ProjectNotFoundError as exc:
486
+ audit.log("web.project.delete", "failed", "web", {"error": str(exc)})
487
+ return redirect(f"/?error={quote_plus(str(exc))}")
488
+ return redirect("/")
489
+
490
+ return app