mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
* feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions): resolve GHES release assets via /api/v3 Generalizes resolve_github_release_asset_api_url to GitHub Enterprise Server hosts (gated by auth.json github hosts), fixing private GHES extension/preset downloads. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions,presets): pass auth.json github hosts into release resolver Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * docs(auth): document GHES private catalog + release-asset auth Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(presets,workflows): pass auth.json github hosts into remaining release resolvers Wires preset add --from and workflow add through github_provider_hosts() so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * test(presets): use module-level io.BytesIO in GHES preset test Addresses Copilot review on PR #3157: drop unnecessary __import__("io") in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is already imported at module level. * fix(github-http): pass through GHES asset API URLs by path shape Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset URL was only returned as already-resolved when its host was in the allowlist; otherwise the resolver returned None and the caller downloaded the same URL without 'Accept: application/octet-stream', fetching JSON metadata instead of the binary. Gate the passthrough on path shape alone, mirroring the github.com case. This is safe: passthrough returns the input URL unchanged and the caller fetches it either way, so no new request to an arbitrary host is induced; the token stays independently gated by auth.json in open_url. The allowlist remains the anti-SSRF gate on the tag-lookup resolving path. Add test_passthrough_for_unlisted_ghes_api_asset_url.
This commit is contained in:
@@ -900,3 +900,45 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# github_provider_hosts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGithubProviderHosts:
|
||||
"""Tests for github_provider_hosts() — the GHES host allowlist source."""
|
||||
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", entries)
|
||||
|
||||
def test_returns_hosts_from_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"),
|
||||
provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "raw.ghes.example")
|
||||
|
||||
def test_empty_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_ignores_non_github_providers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops",
|
||||
auth="basic-pat", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_unions_multiple_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "github.com")
|
||||
|
||||
@@ -16,8 +16,10 @@ import platform
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
from specify_cli.extensions import (
|
||||
@@ -7280,3 +7282,36 @@ class TestExtensionForceCLI:
|
||||
)
|
||||
assert result2.exit_code == 0, strip_ansi(result2.output)
|
||||
assert "installed" in strip_ansi(result2.output)
|
||||
|
||||
|
||||
def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
||||
"""End-to-end wiring: auth.json github host → GHES asset resolution."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
from specify_cli.extensions import ExtensionCatalog
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
||||
auth="bearer", token="t"),
|
||||
])
|
||||
catalog = ExtensionCatalog(tmp_path)
|
||||
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
resolved = catalog._resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip"
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
@@ -188,3 +188,117 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
|
||||
# --- GHES (GitHub Enterprise Server) ---
|
||||
|
||||
def test_resolves_ghes_browser_url_to_api_url(self):
|
||||
"""A GHES browser release URL resolves to the /api/v3 asset URL."""
|
||||
release_json = {
|
||||
"assets": [
|
||||
{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
|
||||
]
|
||||
}
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
self._make_open_url_fn(release_json),
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
|
||||
def test_passthrough_for_existing_ghes_api_asset_url(self):
|
||||
"""An already-resolved GHES /api/v3 asset URL is returned as-is."""
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, lambda *a, **kw: None, github_hosts=("ghes.example",)
|
||||
)
|
||||
assert result == url
|
||||
|
||||
def test_returns_none_for_ghes_host_not_in_allowlist(self):
|
||||
"""Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
recording_open,
|
||||
github_hosts=("other.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
helper gates the token independently, so octet-stream resolution must
|
||||
not be withheld."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, recording_open, github_hosts=("other.example",)
|
||||
)
|
||||
assert result == url
|
||||
assert called == []
|
||||
|
||||
def test_ghes_api_base_preserves_scheme_and_port(self):
|
||||
"""The GHES API base mirrors the URL scheme and keeps a non-standard port."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
"http://localhost:8000/o/r/releases/download/v1/ext.zip",
|
||||
capturing_open,
|
||||
github_hosts=("localhost",),
|
||||
)
|
||||
assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
def test_ghes_wildcard_does_not_match_bare_host(self):
|
||||
"""A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
lambda *a, **kw: None,
|
||||
github_hosts=("*.ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_public_github_url_unaffected_by_github_hosts(self):
|
||||
"""Public github.com still resolves via api.github.com even with github_hosts set."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
||||
capturing_open,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|
||||
|
||||
@@ -17,9 +17,11 @@ import tempfile
|
||||
import shutil
|
||||
import warnings
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -4752,6 +4754,69 @@ class TestPresetAddFromUrlResolution:
|
||||
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'preset add --from <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
manifest_content = yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
||||
})
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("preset.yml", manifest_content)
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"preset", "add",
|
||||
"--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# The tag-lookup call must use the GHES /api/v3 endpoint
|
||||
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# The asset download call must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWrapStrategy:
|
||||
"""Tests for strategy: wrap preset command substitution."""
|
||||
@@ -6021,3 +6086,36 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content,
|
||||
(subdir / f"{template_name}.md").write_text(content)
|
||||
|
||||
return pack_dir
|
||||
|
||||
|
||||
def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
||||
"""End-to-end wiring for presets: auth.json github host → GHES asset resolution."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
from specify_cli.presets import PresetCatalog
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
||||
auth="bearer", token="t"),
|
||||
])
|
||||
catalog = PresetCatalog(tmp_path)
|
||||
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
resolved = catalog._resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v2/pack.zip"
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
||||
|
||||
@@ -5477,6 +5477,137 @@ steps:
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_workflow_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'workflow add <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(self.VALID_WORKFLOW_YAML.encode())
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "add",
|
||||
"https://ghes.example/org/repo/releases/download/v1.0/workflow.yml",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Tag lookup must use the GHES /api/v3 endpoint
|
||||
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# Asset download must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_workflow_add_catalog_based_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'workflow add <id>' with a GHES catalog URL resolves via /api/v3."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
ghes_wf_yaml = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "my-wf"
|
||||
name: "My GHES Workflow"
|
||||
version: "1.0.0"
|
||||
description: "A GHES catalog workflow"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "echo hello"
|
||||
"""
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"}]
|
||||
}).encode())
|
||||
return FakeResponse(ghes_wf_yaml.encode())
|
||||
|
||||
fake_catalog_info = {
|
||||
"id": "my-wf",
|
||||
"name": "My GHES Workflow",
|
||||
"version": "1.0.0",
|
||||
"url": "https://ghes.example/org/repo/releases/download/v2.0/workflow.yml",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info):
|
||||
result = runner.invoke(app, ["workflow", "add", "my-wf"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Tag lookup must use GHES /api/v3
|
||||
tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert "ghes.example/api/v3/repos/org/repo/releases/tags/v2.0" in tag_calls[0]
|
||||
# Asset download must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWorkflowRunExitCodes:
|
||||
"""CLI-level tests for the run/resume process exit codes."""
|
||||
|
||||
Reference in New Issue
Block a user