dev fix, ip controls
This commit is contained in:
@@ -408,6 +408,96 @@ def delete_app_variable(variable_id: int, app_id: str):
|
|||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_ip_access_rules(app_id: str):
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, app_id, ip_cidr, methods, description,
|
||||||
|
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
|
||||||
|
FROM app_ip_access_rules
|
||||||
|
WHERE app_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(app_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_ip_access_rule(rule_id: int, app_id: str):
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
row = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, app_id, ip_cidr, methods, description,
|
||||||
|
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
|
||||||
|
FROM app_ip_access_rules
|
||||||
|
WHERE id = ? AND app_id = ?
|
||||||
|
""",
|
||||||
|
(rule_id, app_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_app_ip_access_rule(app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool):
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app_ip_access_rules (app_id, ip_cidr, methods, description, is_enabled, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(app_id, ip_cidr, methods, description, 1 if is_enabled else 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_app_ip_access_rule(rule_id: int, app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool):
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app_ip_access_rules
|
||||||
|
SET ip_cidr = ?,
|
||||||
|
methods = ?,
|
||||||
|
description = ?,
|
||||||
|
is_enabled = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND app_id = ?
|
||||||
|
""",
|
||||||
|
(ip_cidr, methods, description, 1 if is_enabled else 0, rule_id, app_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_app_ip_access_rule(rule_id: int, app_id: str):
|
||||||
|
run_migrations()
|
||||||
|
con = get_connection()
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM app_ip_access_rules
|
||||||
|
WHERE id = ? AND app_id = ?
|
||||||
|
""",
|
||||||
|
(rule_id, app_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
def update_app_resources(app_id: str, memory: str, cpus: str):
|
def update_app_resources(app_id: str, memory: str, cpus: str):
|
||||||
run_migrations()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|||||||
@@ -267,5 +267,21 @@ def run_migrations():
|
|||||||
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_created ON alert_events(created_at)")
|
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_created ON alert_events(created_at)")
|
||||||
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_job_id ON alert_events(job_id)")
|
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_job_id ON alert_events(job_id)")
|
||||||
|
|
||||||
|
con.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_ip_access_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
ip_cidr TEXT NOT NULL,
|
||||||
|
methods TEXT NOT NULL DEFAULT 'WRITE',
|
||||||
|
description TEXT,
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_app_id ON app_ip_access_rules(app_id)")
|
||||||
|
|
||||||
con.commit()
|
con.commit()
|
||||||
con.close()
|
con.close()
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ from ..config import (
|
|||||||
read_env_value,
|
read_env_value,
|
||||||
)
|
)
|
||||||
from ..db.apps import (
|
from ..db.apps import (
|
||||||
|
create_app_ip_access_rule,
|
||||||
create_app_variable,
|
create_app_variable,
|
||||||
|
delete_app_ip_access_rule,
|
||||||
delete_app_variable,
|
delete_app_variable,
|
||||||
get_app,
|
get_app,
|
||||||
|
get_app_ip_access_rule,
|
||||||
|
get_app_ip_access_rules,
|
||||||
get_app_template,
|
get_app_template,
|
||||||
get_app_deployments,
|
get_app_deployments,
|
||||||
get_app_templates,
|
get_app_templates,
|
||||||
get_app_variables,
|
get_app_variables,
|
||||||
get_apps,
|
get_apps,
|
||||||
|
update_app_ip_access_rule,
|
||||||
update_app_metadata,
|
update_app_metadata,
|
||||||
update_app_resources,
|
update_app_resources,
|
||||||
upsert_created_app,
|
upsert_created_app,
|
||||||
@@ -41,6 +46,20 @@ from ..templates.layout import page, render_result
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DEFAULT_PAGE_SIZE = 20
|
DEFAULT_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
# Selectable method presets for IP access rules. WRITE = POST,PUT,PATCH,DELETE; ALL = every
|
||||||
|
# common HTTP method including GET. The Caddy generator will later interpret these values.
|
||||||
|
IP_ACCESS_METHOD_OPTIONS = (
|
||||||
|
"WRITE",
|
||||||
|
"ALL",
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"GET,POST",
|
||||||
|
"POST,PATCH,DELETE",
|
||||||
|
)
|
||||||
METADATA_FIELDS = (
|
METADATA_FIELDS = (
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
@@ -605,6 +624,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
template_value = render_template_label(templates, app.get("template", "") or "")
|
template_value = render_template_label(templates, app.get("template", "") or "")
|
||||||
environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw)
|
environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw)
|
||||||
variables = get_app_variables(app.get("id", ""))
|
variables = get_app_variables(app.get("id", ""))
|
||||||
|
ip_access_rules = get_app_ip_access_rules(app.get("id", ""))
|
||||||
incidents = get_service_incidents(app.get("id", ""), limit=20)
|
incidents = get_service_incidents(app.get("id", ""), limit=20)
|
||||||
current_health = get_service_health(app.get("id", ""))
|
current_health = get_service_health(app.get("id", ""))
|
||||||
health_history = get_service_health_history(app.get("id", ""), limit=50)
|
health_history = get_service_health_history(app.get("id", ""), limit=50)
|
||||||
@@ -701,6 +721,48 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
if not variable_rows:
|
if not variable_rows:
|
||||||
variable_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné proměnné.</td></tr>'
|
variable_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné proměnné.</td></tr>'
|
||||||
|
|
||||||
|
def render_method_options(selected: str) -> str:
|
||||||
|
selected_upper = (selected or "").upper()
|
||||||
|
options = list(IP_ACCESS_METHOD_OPTIONS)
|
||||||
|
if selected_upper and selected_upper not in options:
|
||||||
|
options.append(selected_upper)
|
||||||
|
out = ""
|
||||||
|
for opt in options:
|
||||||
|
is_sel = " selected" if opt == selected_upper else ""
|
||||||
|
out += f'<option value="{html.escape(opt, quote=True)}"{is_sel}>{html.escape(opt)}</option>'
|
||||||
|
return out
|
||||||
|
|
||||||
|
# IP access rules — inputs in each column are associated with the per-row update form via the
|
||||||
|
# HTML5 form="..." attribute, so the whole row is editable inline while staying valid markup.
|
||||||
|
ip_access_rule_rows = ""
|
||||||
|
for rule in ip_access_rules:
|
||||||
|
rid = html.escape(str(rule.get("id", "")))
|
||||||
|
form_id = f"iprule-{rid}"
|
||||||
|
rule_ip = html.escape(rule.get("ip_cidr", "") or "", quote=True)
|
||||||
|
rule_desc = html.escape(rule.get("description", "") or "", quote=True)
|
||||||
|
rule_enabled = bool(rule.get("is_enabled", 1))
|
||||||
|
row_class = "" if rule_enabled else " rule-disabled"
|
||||||
|
enabled_checked = bool_checked(rule_enabled)
|
||||||
|
ip_label = html.escape(rule.get("ip_cidr", "") or "")
|
||||||
|
ip_access_rule_rows += f"""
|
||||||
|
<tr class="ip-rule-row{row_class}">
|
||||||
|
<td><input name="ip_cidr" value="{rule_ip}" form="{form_id}" required></td>
|
||||||
|
<td><select name="methods" form="{form_id}">{render_method_options(rule.get("methods", ""))}</select></td>
|
||||||
|
<td><input name="description" value="{rule_desc}" form="{form_id}"></td>
|
||||||
|
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivní</label></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
|
||||||
|
<button type="submit" form="{form_id}" class="btn btn-compact"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit</button>
|
||||||
|
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/delete" class="inline-form" onsubmit="return confirm('Smazat pravidlo {ip_label}?');">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-compact"><i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not ip_access_rule_rows:
|
||||||
|
ip_access_rule_rows = '<tr><td colspan="5">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
|
||||||
|
|
||||||
notice = ""
|
notice = ""
|
||||||
if message:
|
if message:
|
||||||
notice = f'<p class="alert">{html.escape(message)}</p>'
|
notice = f'<p class="alert">{html.escape(message)}</p>'
|
||||||
@@ -724,6 +786,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
<div class="detail-tabs" aria-label="Sekce detailu služby">
|
<div class="detail-tabs" aria-label="Sekce detailu služby">
|
||||||
<a class="btn btn-secondary" href="#metadata">Metadata</a>
|
<a class="btn btn-secondary" href="#metadata">Metadata</a>
|
||||||
<a class="btn btn-secondary" href="#promenne">Proměnné</a>
|
<a class="btn btn-secondary" href="#promenne">Proměnné</a>
|
||||||
|
<a class="btn btn-secondary" href="#ip-access">Security / IP Access</a>
|
||||||
<a class="btn btn-secondary" href="#historie">Historie</a>
|
<a class="btn btn-secondary" href="#historie">Historie</a>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -821,6 +884,45 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ip_access_card = (
|
||||||
|
f"""
|
||||||
|
<div class="card" id="ip-access">
|
||||||
|
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody.
|
||||||
|
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
|
||||||
|
Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>IP/CIDR</th>
|
||||||
|
<th>Methods</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
{ip_access_rule_rows}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules" class="metadata-form add-variable-form">
|
||||||
|
<h3>Přidat pravidlo</h3>
|
||||||
|
<label>IP/CIDR</label>
|
||||||
|
<input name="ip_cidr" placeholder="např. 185.10.20.30 nebo 10.0.0.0/24" required>
|
||||||
|
<label>Methods</label>
|
||||||
|
<select name="methods">{render_method_options("WRITE")}</select>
|
||||||
|
<label>Description</label>
|
||||||
|
<input name="description" placeholder="např. Office IP">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" checked> Aktivní</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat pravidlo</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
if can_manage
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
return page(
|
return page(
|
||||||
"Detail slu\u017eby",
|
"Detail slu\u017eby",
|
||||||
f"""
|
f"""
|
||||||
@@ -842,6 +944,8 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
|
|
||||||
{variables_card}
|
{variables_card}
|
||||||
|
|
||||||
|
{ip_access_card}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Souhrn</h2>
|
<h2>Souhrn</h2>
|
||||||
<table>
|
<table>
|
||||||
@@ -1111,6 +1215,94 @@ def apply_app_environment_action(app_id: str, user=Depends(require_developer)):
|
|||||||
return redirect_app_detail(app_id, anchor="promenne", message=message)
|
return redirect_app_detail(app_id, anchor="promenne", message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apps/{app_id}/ip-access-rules")
|
||||||
|
def add_app_ip_access_rule(
|
||||||
|
app_id: str,
|
||||||
|
ip_cidr: str = Form(...),
|
||||||
|
methods: str = Form("WRITE"),
|
||||||
|
description: str = Form(""),
|
||||||
|
is_enabled: str | None = Form(None),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
app = get_app(app_id)
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="App not found")
|
||||||
|
|
||||||
|
rule_ip = clean_optional(ip_cidr)
|
||||||
|
rule_methods = clean_optional(methods).upper()
|
||||||
|
if not rule_ip:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
|
||||||
|
if not rule_methods:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
|
||||||
|
|
||||||
|
create_app_ip_access_rule(app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="app.ip_access_rule.created",
|
||||||
|
target_type="app",
|
||||||
|
target_id=app_id,
|
||||||
|
metadata={"ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
|
||||||
|
)
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo přidáno.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/update")
|
||||||
|
def save_app_ip_access_rule(
|
||||||
|
app_id: str,
|
||||||
|
rule_id: int,
|
||||||
|
ip_cidr: str = Form(...),
|
||||||
|
methods: str = Form("WRITE"),
|
||||||
|
description: str = Form(""),
|
||||||
|
is_enabled: str | None = Form(None),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
app = get_app(app_id)
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="App not found")
|
||||||
|
|
||||||
|
existing = get_app_ip_access_rule(rule_id, app_id)
|
||||||
|
if not existing:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
|
||||||
|
|
||||||
|
rule_ip = clean_optional(ip_cidr)
|
||||||
|
rule_methods = clean_optional(methods).upper()
|
||||||
|
if not rule_ip:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
|
||||||
|
if not rule_methods:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
|
||||||
|
|
||||||
|
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="app.ip_access_rule.updated",
|
||||||
|
target_type="app",
|
||||||
|
target_id=app_id,
|
||||||
|
metadata={"rule_id": rule_id, "ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
|
||||||
|
)
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo upraveno.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/delete")
|
||||||
|
def remove_app_ip_access_rule(app_id: str, rule_id: int, user=Depends(require_developer)):
|
||||||
|
app = get_app(app_id)
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="App not found")
|
||||||
|
|
||||||
|
existing = get_app_ip_access_rule(rule_id, app_id)
|
||||||
|
if not existing:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
|
||||||
|
|
||||||
|
delete_app_ip_access_rule(rule_id, app_id)
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="app.ip_access_rule.deleted",
|
||||||
|
target_type="app",
|
||||||
|
target_id=app_id,
|
||||||
|
metadata={"rule_id": rule_id, "ip_cidr": existing.get("ip_cidr"), "methods": existing.get("methods")},
|
||||||
|
)
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo smazáno.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/apps/{app_id}/redeploy")
|
@router.post("/apps/{app_id}/redeploy")
|
||||||
def redeploy_app(app_id: str, user=Depends(require_developer)):
|
def redeploy_app(app_id: str, user=Depends(require_developer)):
|
||||||
app = get_app(app_id)
|
app = get_app(app_id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import html
|
import html
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from ..auth import require_user
|
from ..auth import require_user
|
||||||
from ..templates.layout import page
|
from ..templates.layout import page
|
||||||
@@ -53,7 +54,7 @@ ROLES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/developers")
|
@router.get("/developers", response_class=HTMLResponse)
|
||||||
def developers_page(user=Depends(require_user)):
|
def developers_page(user=Depends(require_user)):
|
||||||
section_rows = ""
|
section_rows = ""
|
||||||
for icon, title, href, desc, roles in PORTAL_SECTIONS:
|
for icon, title, href, desc, roles in PORTAL_SECTIONS:
|
||||||
|
|||||||
@@ -435,6 +435,16 @@ button:hover,
|
|||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled IP access rules are dimmed so active/inactive rules are visually distinct. */
|
||||||
|
.ip-rule-row.rule-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-rule-row.rule-disabled input,
|
||||||
|
.ip-rule-row.rule-disabled select {
|
||||||
|
background: #f3f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
.info-dot {
|
.info-dot {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user