196 lines
7.8 KiB
Python
196 lines
7.8 KiB
Python
import html
|
||
import os
|
||
from urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||
|
||
from app.auth import require_user
|
||
from app.db.audit import log_audit_event
|
||
from app.env_file import ENV_PATH, KEY_RE, read_entries, save_entries
|
||
from app.routes.runtime import ENV_ACTION_KEYS, render_action_buttons
|
||
from app.templates.layout import page
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def require_admin(user: dict) -> None:
|
||
if (user.get("role") or "").lower() != "admin":
|
||
raise HTTPException(status_code=403, detail="Environment je dostupný pouze administrátorům")
|
||
|
||
|
||
def _render_rows(entries: list[tuple[str, str]]) -> str:
|
||
rows = ""
|
||
for key, value in entries:
|
||
key_html = html.escape(key, quote=True)
|
||
value_html = html.escape(value, quote=True)
|
||
rows += f"""
|
||
<tr>
|
||
<td><input name="key" value="{key_html}" class="env-key" readonly></td>
|
||
<td><input name="value" value="{value_html}" class="env-value"></td>
|
||
<td class="actions-cell">
|
||
<button type="button" class="btn-secondary" onclick="this.closest('tr').remove()">Smazat</button>
|
||
</td>
|
||
</tr>
|
||
"""
|
||
return rows
|
||
|
||
|
||
def _render_page(user: dict, entries: list[tuple[str, str]], message: str = "", error: str = "") -> str:
|
||
notice = ""
|
||
if message:
|
||
notice = f'<p class="alert">{html.escape(message)}</p>'
|
||
if error:
|
||
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
||
|
||
action_buttons = render_action_buttons(ENV_ACTION_KEYS, "/portal/admin/environment")
|
||
|
||
return page(
|
||
"Environment",
|
||
f"""
|
||
<div class="card">
|
||
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Environment</h2>
|
||
<p class="muted">
|
||
Úprava hlavní AppFactory konfigurace <code>{html.escape(ENV_PATH)}</code>.
|
||
Zdroj pravdy zůstává tento soubor. Při uložení se vytvoří časově označený backup.
|
||
Dostupné pouze administrátorům.
|
||
</p>
|
||
{notice}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Proměnné</h2>
|
||
<form method="post" action="/portal/admin/environment">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 30%">Klíč</th>
|
||
<th>Hodnota</th>
|
||
<th style="width: 1%">Akce</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="env-rows">
|
||
{_render_rows(entries)}
|
||
</tbody>
|
||
</table>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-secondary" onclick="envAddRow()"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat klíč</button>
|
||
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit</button>
|
||
</div>
|
||
<p class="muted">
|
||
Klíč musí odpovídat <code>^[A-Z_][A-Z0-9_]*$</code>. Prázdné hodnoty jsou povolené,
|
||
duplicitní klíče ne. Hodnoty s mezerami se uloží v uvozovkách.
|
||
</p>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Jak se změny projeví</h2>
|
||
<ol>
|
||
<li>Proměnné používané shell skripty: stačí znovu spustit příslušný skript.</li>
|
||
<li>Portal / Worker / Webhook / Monitor: je potřeba restart kontejnerů (tlačítka níže).</li>
|
||
<li>Domény, HTTPS a Caddy hodnoty: spusťte <code>generate-caddyfile.sh</code> (Regenerate Caddy).</li>
|
||
<li>UID/GID proměnné: je potřeba opatrný restart/redeploy celého stacku.</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Navazující akce</h2>
|
||
<p class="muted">Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v
|
||
<a href="/portal/admin/runtime">Runtime Management</a> a <a href="/portal/jobs">Úlohách</a>.</p>
|
||
<div class="inline-form">
|
||
{action_buttons}
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function envAddRow() {{
|
||
const tbody = document.getElementById("env-rows");
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = '<td><input name="key" class="env-key" placeholder="NOVY_KLIC"></td>'
|
||
+ '<td><input name="value" class="env-value" placeholder="hodnota"></td>'
|
||
+ '<td class="actions-cell"><button type="button" class="btn-secondary" onclick="this.closest(\\'tr\\').remove()">Smazat</button></td>';
|
||
tbody.appendChild(tr);
|
||
}}
|
||
</script>
|
||
""",
|
||
user=user,
|
||
)
|
||
|
||
|
||
@router.get("/admin/environment", response_class=HTMLResponse)
|
||
def environment_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
|
||
require_admin(user)
|
||
return _render_page(user, read_entries(), message=message, error=error)
|
||
|
||
|
||
@router.post("/admin/environment", response_class=HTMLResponse)
|
||
def environment_save(
|
||
request: Request,
|
||
key: list[str] = Form(default=[]),
|
||
value: list[str] = Form(default=[]),
|
||
user=Depends(require_user),
|
||
):
|
||
require_admin(user)
|
||
|
||
# Sestav uspořádaný seznam dvojic; prázdné řádky (bez klíče i hodnoty) ignoruj.
|
||
pairs: list[tuple[str, str]] = []
|
||
for raw_key, raw_value in zip(key, value):
|
||
k = (raw_key or "").strip()
|
||
v = (raw_value or "").replace("\r", "").replace("\n", " ")
|
||
if not k and not v:
|
||
continue
|
||
pairs.append((k, v))
|
||
|
||
# Validace: klíč podle regexu, žádné duplicity.
|
||
invalid_keys = sorted({k for k, _ in pairs if not KEY_RE.match(k)})
|
||
seen: set[str] = set()
|
||
duplicate_keys = sorted({k for k, _ in pairs if k in seen or seen.add(k)})
|
||
|
||
if invalid_keys or duplicate_keys:
|
||
problems = []
|
||
if invalid_keys:
|
||
problems.append("neplatné klíče: " + ", ".join(invalid_keys))
|
||
if duplicate_keys:
|
||
problems.append("duplicitní klíče: " + ", ".join(duplicate_keys))
|
||
error = "Změny nebyly uloženy — " + "; ".join(problems) + "."
|
||
# Re-render z odeslaných dat, ať admin nepřijde o rozdělanou editaci.
|
||
return _render_page(user, pairs, error=error)
|
||
|
||
# Spočítej změněné klíče (pro audit – bez hodnot).
|
||
original = dict(read_entries())
|
||
new_map = dict(pairs)
|
||
added = set(new_map) - set(original)
|
||
removed = set(original) - set(new_map)
|
||
modified = {k for k in set(new_map) & set(original) if new_map[k] != original[k]}
|
||
changed_keys = sorted(added | removed | modified)
|
||
|
||
if not changed_keys:
|
||
return RedirectResponse(
|
||
url="/portal/admin/environment?message=" + quote("Žádné změny k uložení."),
|
||
status_code=303,
|
||
)
|
||
|
||
try:
|
||
backup_path = save_entries(pairs)
|
||
except OSError as exc:
|
||
return _render_page(user, pairs, error=f"Soubor se nepodařilo uložit: {exc.strerror or exc}")
|
||
|
||
log_audit_event(
|
||
user,
|
||
action="environment.updated",
|
||
target_type="environment",
|
||
target_id=os.path.basename(ENV_PATH),
|
||
metadata={"changed_keys": changed_keys, "backup": os.path.basename(backup_path) if backup_path else None},
|
||
)
|
||
|
||
backup_note = f" Backup: {os.path.basename(backup_path)}." if backup_path else ""
|
||
message = (
|
||
f"Uloženo {len(changed_keys)} změněných klíčů.{backup_note} "
|
||
"Změny se projeví podle typu proměnné různě – viz doporučené akce níže."
|
||
)
|
||
return RedirectResponse(
|
||
url="/portal/admin/environment?message=" + quote(message),
|
||
status_code=303,
|
||
)
|