fixes + guest

This commit is contained in:
JiriUhlir
2026-06-16 10:23:43 +02:00
parent fa4307852c
commit bad55a7d41
18 changed files with 295 additions and 209 deletions
+82 -75
View File
@@ -30,10 +30,10 @@ echo "TODO"
"""
SCHEDULE_TYPES = ("hourly", "daily", "weekly", "monthly")
SCHEDULE_LABELS = {
"hourly": "každou hodinu",
"daily": "denně",
"weekly": "týdně",
"monthly": "měsíčně",
"hourly": "každou hodinu",
"daily": "denně",
"weekly": "týdně",
"monthly": "měsíčně",
}
@@ -70,18 +70,18 @@ def render_enabled_pill(value) -> str:
def render_running_pill(value) -> str:
if value:
return '<span class="pill pill-warning">b&ecaron;&zcaron;&iacute;</span>'
return '<span class="pill pill-muted">neb&ecaron;&zcaron;&iacute;</span>'
return '<span class="pill pill-warning">běží</span>'
return '<span class="pill pill-muted">neběží</span>'
def validate_script_name(value: str) -> str:
script_name = clean_optional(value)
if not script_name:
raise HTTPException(status_code=400, detail="N&aacute;zev skriptu je povinn&yacute;")
raise HTTPException(status_code=400, detail="Název skriptu je povinný")
if not script_name.endswith(".sh"):
raise HTTPException(status_code=400, detail="N&aacute;zev skriptu mus&iacute; kon&ccaron;it .sh")
raise HTTPException(status_code=400, detail="Název skriptu musí končit .sh")
if "/" in script_name or "\\" in script_name or ".." in script_name:
raise HTTPException(status_code=400, detail="N&aacute;zev skriptu nesm&iacute; obsahovat cestu")
raise HTTPException(status_code=400, detail="Název skriptu nesmí obsahovat cestu")
return script_name
@@ -92,7 +92,7 @@ def script_path(script_name: str) -> Path:
try:
path.relative_to(base)
except ValueError:
raise HTTPException(status_code=400, detail="Neplatn&yacute; n&aacute;zev skriptu")
raise HTTPException(status_code=400, detail="Neplatný název skriptu")
return path
@@ -103,33 +103,33 @@ def read_script_file(script_name: str) -> dict:
"exists": False,
"content": DEFAULT_SCRIPT_CONTENT,
"error": None,
"warning": "Soubor zat&iacute;m neexistuje. M&uring;&zcaron;ete ho vytvo&rcaron;it z v&yacute;choz&iacute;ho obsahu.",
"warning": "Soubor zatím neexistuje. Můžete ho vytvořit z výchozího obsahu.",
}
if not path.is_file():
return {"exists": False, "content": "", "error": "Cesta nen&iacute; soubor.", "warning": None}
return {"exists": False, "content": "", "error": "Cesta není soubor.", "warning": None}
try:
if path.stat().st_size > MAX_SCRIPT_BYTES:
return {"exists": True, "content": "", "error": "Soubor je v&ecaron;t&scaron;&iacute; ne&zcaron; 100 KB.", "warning": None}
return {"exists": True, "content": "", "error": "Soubor je větší než 100 KB.", "warning": None}
content = path.read_text(encoding="utf-8")
except Exception:
return {"exists": True, "content": "", "error": "Soubor se nepoda&rcaron;ilo na&ccaron;&iacute;st.", "warning": None}
return {"exists": True, "content": "", "error": "Soubor se nepodařilo načíst.", "warning": None}
warning = None
if not content.startswith("#!/usr/bin/env bash"):
warning = "Prvn&iacute; &rcaron;&aacute;dek by m&ecaron;l b&yacute;t #!/usr/bin/env bash."
warning = "První řádek by měl být #!/usr/bin/env bash."
return {"exists": True, "content": content, "error": None, "warning": warning}
def normalize_script_content(content: str) -> str:
value = content.replace("\r\n", "\n").replace("\r", "\n")
if not value.strip():
raise HTTPException(status_code=400, detail="Obsah skriptu nesm&iacute; b&yacute;t pr&aacute;zdn&yacute;")
raise HTTPException(status_code=400, detail="Obsah skriptu nesmí být prázdný")
if len(value.encode("utf-8")) > MAX_SCRIPT_BYTES:
raise HTTPException(status_code=400, detail="Obsah skriptu je v&ecaron;t&scaron;&iacute; ne&zcaron; 100 KB")
raise HTTPException(status_code=400, detail="Obsah skriptu je větší než 100 KB")
if not value.startswith("#!/usr/bin/env bash"):
value = "#!/usr/bin/env bash\n" + value.lstrip("\n")
if len(value.encode("utf-8")) > MAX_SCRIPT_BYTES:
raise HTTPException(status_code=400, detail="Obsah skriptu je v&ecaron;t&scaron;&iacute; ne&zcaron; 100 KB")
raise HTTPException(status_code=400, detail="Obsah skriptu je větší než 100 KB")
return value
@@ -137,16 +137,23 @@ def save_script_file(script_name: str, content: str) -> None:
path = script_path(script_name)
value = normalize_script_content(content)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8", newline="\n")
except OSError as exc:
reason = exc.strerror or str(exc)
raise HTTPException(status_code=500, detail=f"Soubor se nepodařilo uložit: {reason}")
# Spustitelná práva nastavujeme best-effort soubor už je uložený, takže selhání chmod
# (jiný vlastník, FS bez podpory práv) nesmí hlásit chybu uložení.
try:
os.chmod(path, 0o755)
except Exception:
raise HTTPException(status_code=500, detail="Soubor se nepoda&rcaron;ilo ulo&zcaron;it")
except OSError:
pass
def validate_schedule_type(value: str) -> str:
schedule_type = clean_optional(value)
if schedule_type not in SCHEDULE_TYPES:
raise HTTPException(status_code=400, detail="Neplatn&aacute; frekvence")
raise HTTPException(status_code=400, detail="Neplatná frekvence")
return schedule_type
@@ -154,9 +161,9 @@ def validate_timeout(value: str | int) -> int:
try:
timeout_seconds = int(value)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Timeout mus&iacute; b&yacute;t &ccaron;&iacute;slo")
raise HTTPException(status_code=400, detail="Timeout musí být číslo")
if timeout_seconds < 10 or timeout_seconds > 3600:
raise HTTPException(status_code=400, detail="Timeout mus&iacute; b&yacute;t mezi 10 a 3600 sekundami")
raise HTTPException(status_code=400, detail="Timeout musí být mezi 10 a 3600 sekundami")
return timeout_seconds
@@ -170,7 +177,7 @@ def form_metadata(
) -> dict:
name_value = clean_optional(name)
if not name_value:
raise HTTPException(status_code=400, detail="N&aacute;zev je povinn&yacute;")
raise HTTPException(status_code=400, detail="Název je povinný")
return {
"name": name_value,
"description": clean_optional(description),
@@ -204,7 +211,7 @@ def render_form(script: dict | None, action: str, include_script_name: bool) ->
return f"""
<form method="post" action="{action}" class="metadata-form">
<label>N&aacute;zev</label>
<label>Název</label>
<input name="name" value="{name}" required>
<label>Popis</label>
@@ -215,8 +222,8 @@ def render_form(script: dict | None, action: str, include_script_name: bool) ->
<label>Frekvence</label>
<select name="schedule_type">{render_schedule_options(schedule_type)}</select>
<label>&Ccaron;as</label>
<input name="schedule_time" value="{schedule_time}" placeholder="nap&rcaron;. 02:30">
<label>Čas</label>
<input name="schedule_time" value="{schedule_time}" placeholder="např. 02:30">
<label>Timeout</label>
<input name="timeout_seconds" value="{timeout_seconds}" inputmode="numeric" required>
@@ -224,7 +231,7 @@ def render_form(script: dict | None, action: str, include_script_name: bool) ->
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1"{enabled_checked}> Zapnuto</label>
<div class="form-actions">
<button type="submit">Ulo&zcaron;it</button>
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit</button>
</div>
</form>
"""
@@ -239,7 +246,7 @@ def render_script_content_section(script: dict, user: dict) -> str:
script_file = {
"exists": False,
"content": "",
"error": "N&aacute;zev skriptu nen&iacute; bezpe&ccaron;n&yacute;.",
"error": "Název skriptu není bezpečný.",
"warning": None,
}
content = html.escape(script_file.get("content", "") or "")
@@ -251,7 +258,7 @@ def render_script_content_section(script: dict, user: dict) -> str:
error = f'<p class="alert alert-danger">{script_file["error"]}</p>'
if not is_admin(user):
readonly_hint = '<p class="muted">Obsah skriptu je dostupn&yacute; pouze pro &ccaron;ten&iacute;. Ukl&aacute;dat m&uring;&zcaron;e jen administr&aacute;tor.</p>'
readonly_hint = '<p class="muted">Obsah skriptu je dostupný pouze pro čtení. Ukládat může jen administrátor.</p>'
return f"""
<div class="card">
<h2>Obsah skriptu</h2>
@@ -273,7 +280,7 @@ def render_script_content_section(script: dict, user: dict) -> str:
<label>Obsah souboru</label>
<textarea name="content" rows="22" spellcheck="false">{content}</textarea>
<div class="form-actions">
<button type="submit">Ulo&zcaron;it skript</button>
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit skript</button>
</div>
</form>
</div>
@@ -295,12 +302,12 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
last_run_at = html.escape(script.get("last_run_at", "") or "")
next_run_at = html.escape(script.get("next_run_at", "") or "")
enabled = bool(script.get("is_enabled"))
toggle_label = "Zak&aacute;zat" if enabled else "Povolit"
toggle_label = "Zakázat" if enabled else "Povolit"
delete_button = ""
if not script.get("is_running"):
delete_button = f"""
<form method="post" action="/portal/scheduled-scripts/{script_id}/delete" class="inline-form" onsubmit="return confirm('Smazat pl&aacute;novan&yacute; skript {name}?');">
<button type="submit" class="danger">Smazat</button>
<form method="post" action="/portal/scheduled-scripts/{script_id}/delete" class="inline-form" onsubmit="return confirm('Smazat plánovaný skript {name}?');">
<button type="submit" class="danger"><i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat</button>
</form>
"""
@@ -316,12 +323,12 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
<td>{last_run_at}</td>
<td>{next_run_at}</td>
<td class="actions-cell service-actions">
<a class="btn" href="/portal/scheduled-scripts/{script_id}">Detail</a>
<a class="btn" href="/portal/scheduled-scripts/{script_id}"><i class="fa-solid fa-eye" aria-hidden="true"></i> Detail</a>
<form method="post" action="/portal/scheduled-scripts/{script_id}/run-now" class="inline-form">
<button type="submit" class="btn btn-secondary">Spustit nyn&iacute;</button>
<button type="submit" class="btn btn-secondary"><i class="fa-solid fa-play" aria-hidden="true"></i> Spustit nyní</button>
</form>
<form method="post" action="/portal/scheduled-scripts/{script_id}/toggle" class="inline-form">
<button type="submit" class="btn btn-secondary">{toggle_label}</button>
<button type="submit" class="btn btn-secondary"><i class="fa-solid fa-power-off" aria-hidden="true"></i> {toggle_label}</button>
</form>
{delete_button}
</td>
@@ -329,30 +336,30 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
"""
if not rows:
rows = '<tr><td colspan="10">Zat&iacute;m nejsou evidovan&eacute; &zcaron;&aacute;dn&eacute; pl&aacute;novan&eacute; skripty.</td></tr>'
rows = '<tr><td colspan="10">Zatím nejsou evidované žádné plánované skripty.</td></tr>'
return page(
"Pl&aacute;novan&eacute; skripty",
"Plánované skripty",
f"""
<div class="card">
<h2><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</h2>
<p class="muted">P&rcaron;ehled skript&uring; spou&scaron;t&ecaron;n&yacute;ch schedulerem nebo ru&ccaron;n&ecaron; z port&aacute;lu.</p>
<p><a class="btn" href="/portal/scheduled-scripts/new">+ Nov&yacute; pl&aacute;novan&yacute; skript</a></p>
<h2><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</h2>
<p class="muted">Přehled skriptů spouštěných schedulerem nebo ručně z portálu.</p>
<p><a class="btn" href="/portal/scheduled-scripts/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> Nový plánovaný skript</a></p>
</div>
<div class="card">
<h2>Seznam</h2>
<table>
<tr>
<th>N&aacute;zev</th>
<th>Název</th>
<th>Skript</th>
<th>Frekvence</th>
<th>&Ccaron;as</th>
<th>Čas</th>
<th>Zapnuto</th>
<th>B&ecaron;&zcaron;&iacute;</th>
<th>Posledn&iacute; stav</th>
<th>Posledn&iacute; b&ecaron;h</th>
<th>Dal&scaron;&iacute; b&ecaron;h</th>
<th>Běží</th>
<th>Poslední stav</th>
<th>Poslední běh</th>
<th>Další běh</th>
<th>Akce</th>
</tr>
{rows}
@@ -366,11 +373,11 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
@router.get("/scheduled-scripts/new", response_class=HTMLResponse)
def new_scheduled_script_form(request: Request, user=Depends(require_user)):
return page(
"Nov&yacute; pl&aacute;novan&yacute; skript",
"Nový plánovaný skript",
f"""
<div class="card">
<h2><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Nov&yacute; pl&aacute;novan&yacute; skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t</a></p>
<h2><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Nový plánovaný skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts">&larr; Zpět</a></p>
</div>
<div class="card">
{render_form({"is_enabled": True, "timeout_seconds": 300, "schedule_type": "daily"}, "/portal/scheduled-scripts/new", True)}
@@ -397,7 +404,7 @@ def create_scheduled_script_action(
created_name = metadata["script_name"]
if not script_path(created_name).exists():
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
commit_and_push(f"maintenance/{created_name}", f"Vytvo&rcaron;en skript {created_name}", user)
commit_and_push(f"maintenance/{created_name}", f"Vytvořen skript {created_name}", user)
log_audit_event(
user,
action="scheduled_script.created",
@@ -421,31 +428,31 @@ def scheduled_script_detail(script_id: int, request: Request, user=Depends(requi
last_job = f'<a href="/portal/jobs/{last_job_html}">#{last_job_html}</a>'
enabled = bool(script.get("is_enabled"))
toggle_label = "Zak&aacute;zat" if enabled else "Povolit"
toggle_label = "Zakázat" if enabled else "Povolit"
delete_form = ""
if not script.get("is_running"):
delete_form = f"""
<form method="post" action="/portal/scheduled-scripts/{script_id}/delete" onsubmit="return confirm('Smazat pl&aacute;novan&yacute; skript?');">
<button type="submit" class="danger">Smazat</button>
<form method="post" action="/portal/scheduled-scripts/{script_id}/delete" onsubmit="return confirm('Smazat plánovaný skript?');">
<button type="submit" class="danger"><i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat</button>
</form>
"""
script_content_section = render_script_content_section(script, user)
return page(
html.escape(script.get("name", "") or "Pl&aacute;novan&yacute; skript"),
html.escape(script.get("name", "") or "Plánovaný skript"),
f"""
<div class="card">
<h2><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> {html.escape(script.get("name", "") or "")}</h2>
<p>
<a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t na pl&aacute;novan&eacute; skripty</a>
<a class="btn btn-secondary" href="/portal/scheduled-scripts/{script_id}/edit">Upravit</a>
<a class="btn" href="/portal/scheduled-scripts">&larr; Zpět na plánované skripty</a>
<a class="btn btn-secondary" href="/portal/scheduled-scripts/{script_id}/edit"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit</a>
</p>
<div class="inline-form">
<form method="post" action="/portal/scheduled-scripts/{script_id}/run-now">
<button type="submit">Spustit nyn&iacute;</button>
<button type="submit"><i class="fa-solid fa-play" aria-hidden="true"></i> Spustit nyní</button>
</form>
<form method="post" action="/portal/scheduled-scripts/{script_id}/toggle">
<button type="submit" class="btn-secondary">{toggle_label}</button>
<button type="submit" class="btn-secondary"><i class="fa-solid fa-power-off" aria-hidden="true"></i> {toggle_label}</button>
</form>
{delete_form}
</div>
@@ -454,19 +461,19 @@ def scheduled_script_detail(script_id: int, request: Request, user=Depends(requi
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>N&aacute;zev</th><td>{html.escape(script.get("name", "") or "")}</td></tr>
<tr><th>Název</th><td>{html.escape(script.get("name", "") or "")}</td></tr>
<tr><th>Popis</th><td>{html.escape(script.get("description", "") or "")}</td></tr>
<tr><th>Skript</th><td>{html.escape(script.get("script_name", "") or "")}</td></tr>
<tr><th>Frekvence</th><td>{render_schedule_type(script.get("schedule_type"))}</td></tr>
<tr><th>&Ccaron;as</th><td>{html.escape(script.get("schedule_time", "") or "")}</td></tr>
<tr><th>Čas</th><td>{html.escape(script.get("schedule_time", "") or "")}</td></tr>
<tr><th>Timeout</th><td>{html.escape(str(script.get("timeout_seconds") or ""))}</td></tr>
<tr><th>Zapnuto</th><td>{render_bool(script.get("is_enabled"))}</td></tr>
<tr><th>B&ecaron;&zcaron;&iacute;</th><td>{render_bool(script.get("is_running"))}</td></tr>
<tr><th>Posledn&iacute; stav</th><td>{render_status_pill(script.get("last_status")) if script.get("last_status") else ""}</td></tr>
<tr><th>Posledn&iacute; chyba</th><td>{html.escape(script.get("last_error_text", "") or "")}</td></tr>
<tr><th>Posledn&iacute; b&ecaron;h</th><td>{html.escape(script.get("last_run_at", "") or "")}</td></tr>
<tr><th>Dal&scaron;&iacute; b&ecaron;h</th><td>{html.escape(script.get("next_run_at", "") or "")}</td></tr>
<tr><th>Posledn&iacute; &uacute;loha</th><td>{last_job}</td></tr>
<tr><th>Běží</th><td>{render_bool(script.get("is_running"))}</td></tr>
<tr><th>Poslední stav</th><td>{render_status_pill(script.get("last_status")) if script.get("last_status") else ""}</td></tr>
<tr><th>Poslední chyba</th><td>{html.escape(script.get("last_error_text", "") or "")}</td></tr>
<tr><th>Poslední běh</th><td>{html.escape(script.get("last_run_at", "") or "")}</td></tr>
<tr><th>Další běh</th><td>{html.escape(script.get("next_run_at", "") or "")}</td></tr>
<tr><th>Poslední úloha</th><td>{last_job}</td></tr>
</table>
</div>
@@ -482,11 +489,11 @@ def edit_scheduled_script_form(script_id: int, request: Request, user=Depends(re
if not script:
raise HTTPException(status_code=404, detail="Scheduled script not found")
return page(
"Upravit pl&aacute;novan&yacute; skript",
"Upravit plánovaný skript",
f"""
<div class="card">
<h2><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit pl&aacute;novan&yacute; skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts/{script_id}">&larr; Zp&ecaron;t</a></p>
<h2><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit plánovaný skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts/{script_id}">&larr; Zpět</a></p>
</div>
<div class="card">
{render_form(script, f"/portal/scheduled-scripts/{script_id}/edit", False)}
@@ -556,13 +563,13 @@ def update_scheduled_script_file(
user=Depends(require_user),
):
if not is_admin(user):
raise HTTPException(status_code=403, detail="Skript m&uring;&zcaron;e upravit pouze administr&aacute;tor")
raise HTTPException(status_code=403, detail="Skript může upravit pouze administrátor")
script = get_scheduled_script(script_id)
if not script:
raise HTTPException(status_code=404, detail="Scheduled script not found")
script_name = validate_script_name(script.get("script_name", "") or "")
save_script_file(script_name, content)
commit_and_push(f"maintenance/{script_name}", f"&Uacute;prava skriptu {script_name}", user)
commit_and_push(f"maintenance/{script_name}", f"Úprava skriptu {script_name}", user)
log_audit_event(
user,
action="scheduled_script.file_updated",
@@ -599,7 +606,7 @@ def delete_scheduled_script_action(script_id: int, user=Depends(require_user)):
if not script:
raise HTTPException(status_code=404, detail="Scheduled script not found")
if script.get("is_running"):
raise HTTPException(status_code=409, detail="B&ecaron;&zcaron;&iacute;c&iacute; skript nelze smazat")
raise HTTPException(status_code=409, detail="Běžící skript nelze smazat")
if not delete_scheduled_script(script_id):
raise HTTPException(status_code=409, detail="Skript nelze smazat")
script_name = clean_optional(script.get("script_name"))
@@ -607,7 +614,7 @@ def delete_scheduled_script_action(script_id: int, user=Depends(require_user)):
path = script_path(script_name)
if path.exists():
path.unlink()
commit_and_push(f"maintenance/{script_name}", f"Smaz&aacute;n skript {script_name}", user)
commit_and_push(f"maintenance/{script_name}", f"Smazán skript {script_name}", user)
log_audit_event(
user,
action="scheduled_script.deleted",