diff --git a/app/routes/apps.py b/app/routes/apps.py index fd23872..2637304 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -1,5 +1,6 @@ import html import math +import re from urllib.parse import quote, urlencode from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request @@ -78,6 +79,32 @@ METADATA_FIELDS = ( ) +# ID služby se používá jako Docker container/image name, Compose service name, Gitea repo, +# adresář workspace a část URL/domény. Nejpřísnější je Docker image name (jen lowercase) +# a DNS label (max 63 znaků, alfanumerické na začátku i konci). Proto povolujeme pouze +# malá písmena a-z, číslice a pomlčky uvnitř, bez diakritiky, podtržítek a teček. +APP_ID_MAX_LENGTH = 63 +APP_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +def validate_new_app_id(app_id: str) -> str | None: + """Vrátí chybovou hlášku, pokud ID služby nesplňuje omezení, jinak None.""" + if not app_id: + return "ID služby nesmí být prázdné." + if app_id != app_id.lower(): + return "ID služby musí být malými písmeny (lowercase)." + if len(app_id) > APP_ID_MAX_LENGTH: + return f"ID služby může mít nejvýše {APP_ID_MAX_LENGTH} znaků (limit DNS/Docker)." + if not APP_ID_RE.match(app_id): + return ( + "Neplatné ID služby. Povolena jsou jen malá písmena a-z, číslice a pomlčky; " + "musí začínat i končit písmenem nebo číslicí (např. gmail-service). " + "Bez diakritiky, mezer, podtržítek a teček — ID se používá jako název " + "Docker kontejneru, image a Gitea repozitáře." + ) + return None + + def clean_optional(value: str | None) -> str: return (value or "").strip() @@ -330,6 +357,10 @@ def apps_page( for index, item in enumerate(apps, start=1): app_id = html.escape(item.get("id", "")) app_url_id = quote(item.get("id", ""), safe="") + app_name = html.escape(item.get("name", "") or "") + # Ve sloupci Služba zobrazujeme "Název (id)"; když název chybí nebo je shodný s ID, + # zůstane jen ID bez zdvojení. + service_label = f"{app_name} ({app_id})" if app_name and app_name != app_id else app_id resource_modal_id = f"resources-{page_number}-{index}" status = html.escape(item.get("status", "")) health = latest_health.get(item.get("id", "")) or {} @@ -422,7 +453,7 @@ def apps_page(
{health_dot}
- {app_id}
+ {service_label}
/apps/{app_id}
@@ -1334,7 +1365,11 @@ def new_app_form(request: Request, user=Depends(require_developer)):


- +
+ Jen malá písmena, číslice a pomlčky (např. gmail-service). ID se použije jako název Docker kontejneru, Gitea repozitáře a v URL — později ho nelze změnit.

@@ -1376,6 +1411,20 @@ def create_app( cpus: str = Form(""), user=Depends(require_developer), ): + # Normalizace + validace ID: ID musí být vždy lowercase a splňovat omezení Dockeru, + # DNS a Gitea (viz validate_new_app_id). Bez toho by create script vytvořil kontejner + # nebo repozitář, který později nejde nasadit či adresovat. + app_id = clean_optional(app_id).lower() + app_name = clean_optional(app_name) + + id_error = validate_new_app_id(app_id) + if id_error: + return HTMLResponse(id_error, status_code=400) + if not app_name: + return HTMLResponse("Název služby nesmí být prázdný.", status_code=400) + if get_app(app_id): + return HTMLResponse(f"Služba s ID '{app_id}' už existuje.", status_code=400) + selected_template = get_app_template(template, create_enabled=True) if not selected_template: return HTMLResponse("Nepodporovaná šablona", status_code=400) diff --git a/app/static/portal.js b/app/static/portal.js index 42ba1e5..1548c95 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -149,6 +149,20 @@ document.addEventListener("DOMContentLoaded", () => { const status = document.getElementById("new-app-status"); const result = document.getElementById("new-app-result"); + // ID služby musí být vždy lowercase (Docker image/container, Gitea repo, URL). + // Přepisujeme rovnou při psaní, ať uživatel vidí skutečnou hodnotu, která se odešle. + const appIdInput = form.querySelector('input[name="app_id"]'); + if (appIdInput) { + appIdInput.addEventListener("input", () => { + const lowered = appIdInput.value.toLowerCase(); + if (appIdInput.value !== lowered) { + const cursor = appIdInput.selectionStart; + appIdInput.value = lowered; + appIdInput.setSelectionRange(cursor, cursor); + } + }); + } + form.addEventListener("submit", async (event) => { event.preventDefault();