Files
appfactory-portal/app/routes/environment.py
T
2026-06-17 11:39:52 +02:00

196 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="icon-action icon-action-danger" onclick="this.closest('tr').remove()" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></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/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="icon-action icon-action-danger" onclick="this.closest(\\'tr\\').remove()" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></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,
)