mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
refactor(tls): add url.origin and use it wherever url.base lost its trailing slash
lookup('tls', app, 'url.base') always ends in '/', and 39 call sites in 25 files rebuilt the slash-less form by hand with regex_replace('/$', ''), regex_replace('/+$', ''), trim('/') or .rstrip('/'), directly or on a variable holding url.base. The lookup now returns that value as url.origin (scheme://host, url.base minus its final '/'), and every call site reads it; two sites that stripped the slash only to append it again now take url.base as is, and GITLAB_URL and DASHBOARD_JS_CDN_URL lost their last consumer.
test_url_base_no_double_slash gains the rule url-base-strip, so url.origin stays the only way to the slash-less URL; it flags all nine HEAD sites probed and none after the change. The same lint keeps url-base-double-slash and url-base-addon-value, which put the nine Nextcloud integration URLs, the Mattermost and SuiteCRM facts and the XWiki instance on the slash-less form, drop the stray '/' before Joomla's index.php callback, and give BBB its api.url as [base, suffix] | url_join plus the trailing '/' its client appends 'api/<call>' to; bbb.spec.js now requires exactly that form.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
199d7fdcbf
commit
44aaa68924
44 files changed
+291
-56
No files matched your search
@@ -14,8 +14,7 @@
|
||||
# Helper Variables:
|
||||
_oidc_client_realm: "{{ OIDC_OVERRIDE.CLIENT.REALM if OIDC_OVERRIDE is defined and OIDC_OVERRIDE.CLIENT is defined and OIDC_OVERRIDE.CLIENT.REALM is defined else
|
||||
SOFTWARE_DOMAIN }}"
|
||||
_oidc_url: "{{ ( OIDC_OVERRIDE.URL if (OIDC_OVERRIDE is defined and OIDC_OVERRIDE.URL is defined) else (lookup('tls', 'web-app-keycloak', 'url.base')) ).rstrip('/')
|
||||
}}"
|
||||
_oidc_url: "{{ OIDC_OVERRIDE.URL.rstrip('/') if (OIDC_OVERRIDE is defined and OIDC_OVERRIDE.URL is defined) else lookup('tls', 'web-app-keycloak', 'url.origin') }}"
|
||||
_oidc_client_issuer_url: "{{ _oidc_url ~ '/realms/' ~ _oidc_client_realm }}"
|
||||
_oidc_client_id: "{{ OIDC_OVERRIDE.CLIENT.ID if OIDC_OVERRIDE is defined and OIDC_OVERRIDE.CLIENT is defined and OIDC_OVERRIDE.CLIENT.ID is defined else SOFTWARE_DOMAIN
|
||||
}}"
|
||||
|
||||
@@ -101,7 +101,8 @@ class LookupModule(LookupBase):
|
||||
web_protocol = "https" if enabled else "http"
|
||||
websocket_protocol = "wss" if enabled else "ws"
|
||||
web_port = 443 if enabled else 80
|
||||
base_url = f"{web_protocol}://{primary_domain}/"
|
||||
origin = f"{web_protocol}://{primary_domain}"
|
||||
base_url = f"{origin}/"
|
||||
|
||||
resolved: dict[str, Any] = {
|
||||
"application_id": app_id,
|
||||
@@ -111,7 +112,7 @@ class LookupModule(LookupBase):
|
||||
"domains": {"primary": primary_domain, "all": all_domains},
|
||||
"protocols": {"web": web_protocol, "websocket": websocket_protocol},
|
||||
"ports": {"web": web_port},
|
||||
"url": {"base": base_url},
|
||||
"url": {"base": base_url, "origin": origin},
|
||||
}
|
||||
|
||||
if want:
|
||||
|
||||
@@ -34,7 +34,7 @@ ngx.ctx.buf = nil
|
||||
{% if _mirror_tor_enabled %}
|
||||
{% set _mirror_origins = lookup('applications') | external_asset_origins(application_id, DOMAIN_PRIMARY) %}
|
||||
{% set _mirror_tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %}
|
||||
{% set _mirror_onion = lookup('tls', 'web-svc-mirror', 'url.base') | regex_replace('/+$', '') | to_onion_url(_mirror_tor_node, DOMAIN_PRIMARY) %}
|
||||
{% set _mirror_onion = lookup('tls', 'web-svc-mirror', 'url.origin') | to_onion_url(_mirror_tor_node, DOMAIN_PRIMARY) %}
|
||||
{% if _mirror_origins %}
|
||||
-- Onion sessions: rewrite external asset origins to the local mirror (privacy proxy)
|
||||
do
|
||||
@@ -54,7 +54,7 @@ if ngx.ctx.is_css then
|
||||
ngx.arg[1] = whole
|
||||
return
|
||||
end
|
||||
{% set _cdn_base = lookup('tls', 'web-svc-cdn', 'url.base') | regex_replace('/+$', '') %}
|
||||
{% set _cdn_base = lookup('tls', 'web-svc-cdn', 'url.origin') %}
|
||||
{% set _tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %}
|
||||
{% set _cdn_onion = _cdn_base | to_onion_url(_tor_node, DOMAIN_PRIMARY) %}
|
||||
local function onionize(s)
|
||||
|
||||
@@ -12,13 +12,13 @@ const KC_CONFIG = {
|
||||
realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud
|
||||
clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud
|
||||
redirectUri: "{{ DASHBOARD_APP_BASE_URL }}", // canonical app URL for login/logout round-trips
|
||||
silentCheckSsoRedirectUri: "{{ (DASHBOARD_APP_BASE_URL | regex_replace('/+$', '')) ~ DASHBOARD_LOCATION_SILENT_CHECK }}"
|
||||
silentCheckSsoRedirectUri: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.origin') ~ DASHBOARD_LOCATION_SILENT_CHECK }}"
|
||||
};
|
||||
|
||||
{% set _oidc_tor_enabled = lookup('config', application_id, 'services.tor.enabled', False) | bool %}
|
||||
{% if _oidc_tor_enabled %}
|
||||
{% set _oidc_tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %}
|
||||
{% set _oidc_mirror_onion = lookup('tls', 'web-svc-mirror', 'url.base') | regex_replace('/+$', '') | to_onion_url(_oidc_tor_node, DOMAIN_PRIMARY) %}
|
||||
{% set _oidc_mirror_onion = lookup('tls', 'web-svc-mirror', 'url.origin') | to_onion_url(_oidc_tor_node, DOMAIN_PRIMARY) %}
|
||||
const KC_MIRROR_BASE = "{{ _oidc_mirror_onion }}";
|
||||
{% else %}
|
||||
const KC_MIRROR_BASE = null;
|
||||
|
||||
@@ -14,13 +14,12 @@ DASHBOARD_SIMPLEICONS_PROBE_URLS: >-
|
||||
| map('regex_replace', '^', DASHBOARD_SIMPLEICONS_SYNC_URL_BASE)
|
||||
| map('regex_replace', '$', '.svg'))) }}
|
||||
|
||||
DASHBOARD_JS_CDN_URL: "{{ lookup('tls', 'web-svc-cdn', 'url.base') }}"
|
||||
DASHBOARD_JS_FILES: >-
|
||||
{{ ['iframe.js']
|
||||
+ (['oidc.js'] if (DASHBOARD_OIDC_ENABLED | bool) else []) }}
|
||||
DASHBOARD_JS_BASE_PATH: "{{ DASHBOARD_APPLICATION_ID | get_entity_name }}/js"
|
||||
DASHBOARD_JS_SERVER_DIR: "{{ [ lookup('nginx','directories.data.cdn'), DASHBOARD_JS_BASE_PATH ] | path_join }}"
|
||||
DASHBOARD_JS_BASE_URL: "{{ (DASHBOARD_JS_CDN_URL | trim('/')) ~ '/' ~ (DASHBOARD_JS_BASE_PATH | trim('/')) }}"
|
||||
DASHBOARD_JS_BASE_URL: "{{ lookup('tls', 'web-svc-cdn', 'url.origin') ~ '/' ~ (DASHBOARD_JS_BASE_PATH | trim('/')) }}"
|
||||
|
||||
DASHBOARD_LOCATION_SILENT_CHECK: "/silent-check-sso.html"
|
||||
|
||||
@@ -28,7 +27,7 @@ DASHBOARD_CONFIG_YML_SRC_FILE: "config.yaml.j2"
|
||||
DASHBOARD_CONFIG_INV_PATH: "{{ [ inventory_dir, 'files', inventory_hostname ,'docker', DASHBOARD_APPLICATION_ID, DASHBOARD_CONFIG_YML_SRC_FILE] | path_join }}"
|
||||
DASHBOARD_CONFIG_YML_DEST: "{{ [ lookup('container', DASHBOARD_APPLICATION_ID, 'directories.volumes'), 'config.yaml' ] | path_join }}"
|
||||
|
||||
DASHBOARD_APP_BASE_URL: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.base') | regex_replace('/+$', '') }}/"
|
||||
DASHBOARD_APP_BASE_URL: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.base') }}"
|
||||
DASHBOARD_KEYCLOAK_LOGIN_URL: >-
|
||||
{{ OIDC.CLIENT.AUTHORIZE_URL
|
||||
~ '?client_id=' ~ OIDC.CLIENT.ID
|
||||
|
||||
@@ -2,7 +2,7 @@ ADMIN_EMAIL={{ lookup('users', 'administrator').email | dotenv_quote }}
|
||||
|
||||
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') | dotenv_quote }}
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.origin') | dotenv_quote }}
|
||||
|
||||
BIBER_EMAIL={{ lookup('users', 'biber').email | dotenv_quote }}
|
||||
|
||||
@@ -12,7 +12,7 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
|
||||
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
|
||||
|
||||
DECIDIM_BASE_URL={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') | dotenv_quote }}
|
||||
DECIDIM_BASE_URL={{ lookup('tls', application_id, 'url.origin') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
bench set-config -g redis_queue "{{ ERPNEXT_REDIS_URL }}/1";
|
||||
bench set-config -g redis_socketio "{{ ERPNEXT_REDIS_URL }}/2";
|
||||
bench set-config -gp socketio_port {{ ERPNEXT_WEBSOCKET_PORT }};
|
||||
bench set-config -g host_name "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}";
|
||||
bench set-config -g host_name "{{ lookup('tls', application_id, 'url.origin') }}";
|
||||
{{ lookup('container_volumes', service_name) | indent(4) }}
|
||||
{{ lookup('container_depends_on', application_id) | indent(4) }}
|
||||
{{ lookup('container_extra_hosts') | indent(4) }}
|
||||
|
||||
@@ -8,7 +8,7 @@ FRIENDICA_IMAGE: "{{ lookup('config', application_id, 'services.' ~ FRIENDICA_SE
|
||||
FRIENDICA_VERSION: "{{ lookup('config', application_id, 'services.' ~ FRIENDICA_SERVICE ~ '.version') }}"
|
||||
FRIENDICA_INIT_GATE_FILE: "friendica-init-gate.sh"
|
||||
FRIENDICA_INIT_GATE_HOST: "{{ [lookup('container', application_id, 'directories.instance'), FRIENDICA_INIT_GATE_FILE] | path_join }}"
|
||||
FRIENDICA_BASE_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
|
||||
FRIENDICA_BASE_URL: "{{ lookup('tls', application_id, 'url.origin') }}"
|
||||
|
||||
FRIENDICA_APPLICATION_DIR: "{{ lookup('volume', 'web-app-friendica', 'html').target }}"
|
||||
FRIENDICA_CONFIG_DIR: "{{ [ FRIENDICA_APPLICATION_DIR, 'config' ] | path_join }}"
|
||||
|
||||
@@ -4,7 +4,6 @@ entity_name: "{{ application_id | get_entity_name }}"
|
||||
|
||||
swarm_stack_converge_retries: 600
|
||||
|
||||
GITLAB_URL: "{{ lookup('tls', application_id, 'url.base') }}"
|
||||
GITLAB_HOSTNAME: "{{ lookup('container_hostname', application_id) }}"
|
||||
GITLAB_TLS_ENABLED: "{{ lookup('tls', application_id, 'enabled') | bool }}"
|
||||
GITLAB_WEB_PORT: "{{ lookup('tls', application_id, 'ports.web') }}"
|
||||
@@ -27,7 +26,7 @@ GITLAB_OIDC_UID_FIELD: "{{ OIDC.ATTRIBUTES.USERNAME }}"
|
||||
GITLAB_OIDC_CLIENT_ID: "{{ OIDC.CLIENT.ID }}"
|
||||
GITLAB_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}"
|
||||
GITLAB_OIDC_ISSUER: "{{ OIDC.CLIENT.ISSUER_URL }}"
|
||||
GITLAB_OIDC_REDIRECT_URI: "{{ (GITLAB_URL | regex_replace('/$', '')) ~ '/users/auth/openid_connect/callback' }}"
|
||||
GITLAB_OIDC_REDIRECT_URI: "{{ lookup('tls', application_id, 'url.origin') ~ '/users/auth/openid_connect/callback' }}"
|
||||
|
||||
GITLAB_INIT_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}"
|
||||
|
||||
|
||||
@@ -53,4 +53,4 @@ jenkins:
|
||||
- group: "authenticated"
|
||||
unclassified:
|
||||
location:
|
||||
url: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}/"
|
||||
url: "{{ lookup('tls', application_id, 'url.base') }}"
|
||||
@@ -11,7 +11,7 @@ config:
|
||||
issuer_url: "{{ OIDC.CLIENT.ISSUER_URL }}"
|
||||
client_id: "{{ OIDC.CLIENT.ID }}"
|
||||
client_secret: "{{ OIDC.CLIENT.SECRET }}"
|
||||
redirect_uri: "{{ lookup('tls', 'web-app-joomla', 'url.base') }}/index.php?option=keycloak&task=callback"
|
||||
redirect_uri: "{{ lookup('tls', 'web-app-joomla', 'url.base') }}index.php?option=keycloak&task=callback"
|
||||
end_session_url: "{{ OIDC.CLIENT.LOGOUT_URL }}"
|
||||
fallback_enabled: "{{ lookup('config', 'web-app-joomla', 'services.sso.oidc.fallback_enabled') | string | lower }}"
|
||||
group_admin: "/{{ lookup('rbac_group_path', application_id='web-app-joomla', role='administrator') }}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
window.__INFINITO_LOGOUT__ = {
|
||||
origin: {{ lookup('tls', 'web-svc-logout', 'url.base') | regex_replace('/$', '') | to_json }},
|
||||
origin: {{ lookup('tls', 'web-svc-logout', 'url.origin') | to_json }},
|
||||
i18n: {{ lookup('file', application_id | abs_role_path_by_application_id ~ '/files/logout_i18n.yml') | from_yaml | to_json }}
|
||||
};
|
||||
{{ lookup('file', application_id | abs_role_path_by_application_id ~ '/files/javascript/logout-panel.js') }}
|
||||
@@ -6,7 +6,7 @@ domain: "{{ lookup('domain',application_id) }}"
|
||||
KEYCLOAK_CONTAINER_HOSTNAME: "{{ entity_name }}"
|
||||
KEYCLOAK_DOMAIN: "{{ domain }}"
|
||||
KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}"
|
||||
KEYCLOAK_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
|
||||
KEYCLOAK_URL: "{{ lookup('tls', application_id, 'url.origin') }}"
|
||||
KEYCLOAK_REALM_URL: "{{ KEYCLOAK_URL }}/realms/{{ KEYCLOAK_REALM }}"
|
||||
KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}"
|
||||
KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% include 'roles/sys-svc-container/templates/env.j2' %}
|
||||
|
||||
MOODLE_WWWROOT={{ lookup('tls', application_id, 'url.base') | regex_replace('/$', '') }}
|
||||
MOODLE_WWWROOT={{ lookup('tls', application_id, 'url.origin') }}
|
||||
MOODLE_INTERNAL_HOST={{ MOODLE_MCP_SERVICE_KEY }}
|
||||
MOODLE_DATAROOT={{ MOODLE_DATA_DIR }}
|
||||
MOODLE_LOCALCACHEDIR={{ MOODLE_LOCAL_CACHE_DIR }}
|
||||
|
||||
@@ -63,8 +63,8 @@ test("bbb addon: cloud_bbb app route renders its own UI and is coupled to the pa
|
||||
|
||||
expect(
|
||||
configuredUrl,
|
||||
"the bbb api.url must be a valid https URL pointing at the BigBlueButton API mount (the partner base URL + '/bigbluebutton/' API suffix)"
|
||||
).toMatch(/^https:\/\/.+\/bigbluebutton\/?$/);
|
||||
"the bbb api.url must be the partner base URL + '/bigbluebutton/' with exactly one slash on each side; the BigBlueButton client appends 'api/<call>' directly, so a missing trailing slash yields '/bigbluebuttonapi/'"
|
||||
).toMatch(/^https:\/\/[^/]+\/bigbluebutton\/$/);
|
||||
|
||||
const apiSecretField = bbbSection.locator("#bbb-api input[name='api.secret'], input[name='api.secret']");
|
||||
await expect(
|
||||
|
||||
@@ -16,4 +16,4 @@ config:
|
||||
configvalue: "{{ lookup('config','web-app-bigbluebutton', 'secrets.credentials.shared_secret', '') }}"
|
||||
- appid: "bbb"
|
||||
configkey: "api.url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-bigbluebutton', 'url.base') }}{{ lookup('config', 'web-app-bigbluebutton', 'services.bigbluebutton.api_suffix','') }}"
|
||||
configvalue: "{{ ([lookup('tls', 'web-app-bigbluebutton', 'url.base'), lookup('config', 'web-app-bigbluebutton', 'services.bigbluebutton.api_suffix', '')] | url_join) ~ '/' }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_discourse"
|
||||
configkey: "url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-discourse', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-discourse', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_gitlab"
|
||||
configkey: "oauth_instance_url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-gitlab', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-gitlab', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_mastodon"
|
||||
configkey: "oauth_instance_url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-mastodon', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-mastodon', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_matrix"
|
||||
configkey: "oauth_instance_url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-matrix', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-matrix', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_mattermost"
|
||||
configkey: "url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-mattermost', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_moodle"
|
||||
configkey: "url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-moodle', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-moodle', 'url.origin') }}"
|
||||
@@ -11,5 +11,5 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_openai"
|
||||
configkey: "url"
|
||||
configvalue: "{{ (lookup('tls', 'web-app-openwebui', 'url.base') | regex_replace('/+$', '') ~ '/api') if ('web-app-openwebui' in group_names) else (LITELLM_BASE_LOCAL_URL
|
||||
configvalue: "{{ (lookup('tls', 'web-app-openwebui', 'url.origin') ~ '/api') if ('web-app-openwebui' in group_names) else (LITELLM_BASE_LOCAL_URL
|
||||
~ '/v1') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_suitecrm"
|
||||
configkey: "url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-suitecrm', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-suitecrm', 'url.origin') }}"
|
||||
@@ -9,4 +9,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_zammad"
|
||||
configkey: "oauth_instance_url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-zammad', 'url.base') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-app-zammad', 'url.origin') }}"
|
||||
@@ -16,7 +16,7 @@ config:
|
||||
configvalue: "{{ NEXTCLOUD_COLLABORA_URL }}"
|
||||
- appid: "richdocuments"
|
||||
configkey: "public_wopi_url"
|
||||
configvalue: "{{ NEXTCLOUD_COLLABORA_URL | regex_replace('/$', '') }}"
|
||||
configvalue: "{{ lookup('tls', 'web-svc-collabora', 'url.origin') }}"
|
||||
- appid: "richdocuments"
|
||||
configkey: "disable_certificate_verification"
|
||||
configvalue: 0
|
||||
@@ -15,4 +15,4 @@ config:
|
||||
configkey: "instances"
|
||||
configvalue:
|
||||
- name: "XWiki"
|
||||
url: "{{ lookup('tls', 'web-app-xwiki', 'url.base') }}"
|
||||
url: "{{ lookup('tls', 'web-app-xwiki', 'url.origin') }}"
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
- name: 🔍 Resolve GitLab integration provisioning facts
|
||||
set_fact:
|
||||
nextcloud_gl_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.base') | regex_replace('/$', '') }}"
|
||||
nextcloud_gl_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.origin') }}"
|
||||
changed_when: false
|
||||
|
||||
- name: 📖 Read persisted integration_gitlab OAuth client id
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# partner and wires the returned credentials into Nextcloud.
|
||||
- name: 🔍 Resolve Mattermost partner coupling facts
|
||||
set_fact:
|
||||
nextcloud_mm_base_url: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}"
|
||||
nextcloud_mm_base_url: "{{ lookup('tls', 'web-app-mattermost', 'url.origin') }}"
|
||||
nextcloud_mm_local_url: "http://{{ DOCKER_REACH_HOST }}:{{ lookup('config', 'web-app-mattermost', 'services.mattermost.ports.local.http') }}"
|
||||
nextcloud_mm_redirect_path: "/apps/integration_mattermost/oauth-redirect"
|
||||
nextcloud_mm_exec_chdir: "{{ lookup('container', 'web-app-mattermost', 'directories.instance') if DEPLOYMENT_MODE == 'compose' else omit }}"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
set_fact:
|
||||
nextcloud_crm_container: "{{ lookup('config', 'web-app-suitecrm', 'services.suitecrm.name') }}"
|
||||
nextcloud_crm_instance_dir: "{{ lookup('container', 'web-app-suitecrm', 'directories.instance') }}"
|
||||
nextcloud_crm_base_url: "{{ lookup('tls', 'web-app-suitecrm', 'url.base') | trim }}"
|
||||
nextcloud_crm_base_url: "{{ lookup('tls', 'web-app-suitecrm', 'url.origin') }}"
|
||||
nextcloud_crm_client_name: "Nextcloud ({{ lookup('domain', 'web-app-nextcloud') }})"
|
||||
nextcloud_crm_client_secret: "{{ ('integration_suitecrm' ~ lookup('users', 'administrator').password) | hash('sha256') }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# integration_gitlab coupling does.
|
||||
- name: 🔍 Resolve Zammad integration provisioning facts
|
||||
set_fact:
|
||||
nextcloud_zammad_base_url: "{{ lookup('tls', 'web-app-zammad', 'url.base') | regex_replace('/$', '') }}"
|
||||
nextcloud_zammad_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.base') | regex_replace('/$', '') }}"
|
||||
nextcloud_zammad_base_url: "{{ lookup('tls', 'web-app-zammad', 'url.origin') }}"
|
||||
nextcloud_zammad_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.origin') }}"
|
||||
changed_when: false
|
||||
|
||||
- name: 📖 Read persisted integration_zammad OAuth client id
|
||||
|
||||
@@ -256,7 +256,7 @@ NEXTCLOUD_COLLABORA_URL: "{{ lookup('tls', 'web-svc-collabora', 'url.base') }}"
|
||||
NEXTCLOUD_ONLYOFFICE_URL: "{{ lookup('tls', 'web-svc-onlyoffice', 'url.base') }}"
|
||||
NEXTCLOUD_ONLYOFFICE_ENABLED: "{{ lookup('config', application_id, 'addons.onlyoffice.enabled') }}"
|
||||
|
||||
NEXTCLOUD_OPENPROJECT_URL: "{{ lookup('tls', 'web-app-openproject', 'url.base') | regex_replace('/$', '') }}"
|
||||
NEXTCLOUD_OPENPROJECT_URL: "{{ lookup('tls', 'web-app-openproject', 'url.origin') }}"
|
||||
NEXTCLOUD_OPENPROJECT_CLIENT_NAME: "OpenProject client"
|
||||
|
||||
NEXTCLOUD_DOCKER_USER_ID: 82
|
||||
|
||||
@@ -17,7 +17,7 @@ OPENBAO_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enable
|
||||
OPENBAO_PKI_ENABLED: "{{ lookup('config', application_id, 'services.openbao.pki.enabled') }}"
|
||||
OPENBAO_LOG_LEVEL: "{{ lookup('config', application_id, 'services.openbao.log_level') }}"
|
||||
|
||||
OPENBAO_BASE_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
|
||||
OPENBAO_BASE_URL: "{{ lookup('tls', application_id, 'url.origin') }}"
|
||||
|
||||
OPENBAO_CONFIG_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'openbao.hcl'] | path_join }}"
|
||||
OPENBAO_PLUGINS_CONFIG_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'plugins.hcl'] | path_join }}"
|
||||
|
||||
@@ -6,7 +6,7 @@ proxy_x_forwarded_proto: "https"
|
||||
|
||||
OPENCLOUD_CONTAINER: "{{ lookup('config', application_id, 'services.opencloud.name') }}"
|
||||
|
||||
OPENCLOUD_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
|
||||
OPENCLOUD_URL: "{{ lookup('tls', application_id, 'url.origin') }}"
|
||||
OPENCLOUD_DOMAIN: "{{ lookup('domain', application_id) }}"
|
||||
|
||||
OPENCLOUD_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
|
||||
|
||||
@@ -42,7 +42,7 @@ OPENTALK_RECORDER_HTTP_PORT: "{{ lookup('config', application_id, 'services.reco
|
||||
OPENTALK_RECORDER_API_KEY_ID: "recorder"
|
||||
OPENTALK_RECORDER_API_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.recorder_api_secret') }}"
|
||||
|
||||
OPENTALK_KEYCLOAK_BASE_URL: "{{ lookup('tls', 'web-app-keycloak', 'url.base') | regex_replace('/+$','') }}"
|
||||
OPENTALK_KEYCLOAK_BASE_URL: "{{ lookup('tls', 'web-app-keycloak', 'url.origin') }}"
|
||||
OPENTALK_KEYCLOAK_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-keycloak', 'keycloak') }}"
|
||||
OPENTALK_KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% include 'roles/sys-svc-container/templates/env.j2' %}
|
||||
|
||||
SEMAPHORE_PORT=:{{ container_port }}
|
||||
SEMAPHORE_WEB_ROOT={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}
|
||||
SEMAPHORE_WEB_ROOT={{ lookup('tls', application_id, 'url.origin') }}
|
||||
|
||||
SEMAPHORE_DB_DIALECT=postgres
|
||||
SEMAPHORE_DB_HOST={{ lookup('database', application_id, 'host') }}
|
||||
|
||||
@@ -27,7 +27,7 @@ SEMAPHORE_OIDC_PROVIDERS_JSON: >-
|
||||
"provider_url": OIDC.CLIENT.ISSUER_URL,
|
||||
"client_id": OIDC.CLIENT.ID,
|
||||
"client_secret": OIDC.CLIENT.SECRET,
|
||||
"redirect_url": (lookup('tls', application_id, 'url.base') | regex_replace('/+$', '')) ~ "/api/auth/oidc/keycloak/redirect",
|
||||
"redirect_url": lookup('tls', application_id, 'url.origin') ~ "/api/auth/oidc/keycloak/redirect",
|
||||
"username_claim": "preferred_username",
|
||||
"email_claim": "email",
|
||||
"name_claim": "name",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
APP_ENV={{ ENVIRONMENT | lower }}
|
||||
APP_DEBUG={{MODE_DEBUG | string | lower }}
|
||||
APP_KEY={{ lookup('config', application_id, 'secrets.credentials.app_key')}}
|
||||
APP_URL={{ SNIPE_IT_URL | regex_replace('/$', '') }}
|
||||
APP_URL={{ lookup('tls', application_id, 'url.origin') }}
|
||||
APP_TIMEZONE={{ HOST_TIMEZONE | dotenv_quote }}
|
||||
APP_LOCALE={{ HOST_LL_CC }}
|
||||
MAX_RESULTS=500
|
||||
|
||||
@@ -9,4 +9,4 @@ flags:
|
||||
unsafe-eval: true # nocheck: csp-unsafe Reason: the WebAdmin bundle builds functions dynamically at runtime; blocked, the SPA aborts during boot with an EvalError before it renders.
|
||||
whitelist:
|
||||
connect-src:
|
||||
- "{{ lookup('tls', 'web-app-keycloak', 'url.base') | regex_replace('/+$', '') }}"
|
||||
- "{{ lookup('tls', 'web-app-keycloak', 'url.origin') }}"
|
||||
@@ -16,7 +16,7 @@
|
||||
255-char column.
|
||||
#}
|
||||
{% set stalwart_kc_webmail_base = lookup('canonical_url', 'web-app-stalwart', 'webmail') %}
|
||||
{% set stalwart_kc_webui_base = lookup('tls', 'web-app-stalwart', 'url.base') | regex_replace('/+$', '') %}
|
||||
{% set stalwart_kc_webui_base = lookup('tls', 'web-app-stalwart', 'url.origin') %}
|
||||
{
|
||||
"clientId": "{{ lookup('config', 'web-app-stalwart', 'services.sso.oidc.clients.webui') }}",
|
||||
"name": "Stalwart WebAdmin",
|
||||
|
||||
@@ -8,7 +8,7 @@ location = /logout {
|
||||
|
||||
{% include 'roles/sys-svc-proxy/templates/headers/upstream_strips.conf.j2' %}
|
||||
|
||||
{%- set aca_origin = (lookup('tls', 'web-svc-logout', 'url.base') | regex_replace('/$', '')) ~ " always" -%}
|
||||
{%- set aca_origin = lookup('tls', 'web-svc-logout', 'url.origin') ~ " always" -%}
|
||||
{%- set aca_credentials = "'true' always" -%}
|
||||
{%- set aca_methods = "'GET, OPTIONS' always" -%}
|
||||
{%- set aca_headers = "'Accept, Authorization' always" -%}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_at
|
||||
from utils.cache.files import iter_project_files_with_content
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_RULE = "url-base-double-slash"
|
||||
_ADDON_RULE = "url-base-addon-value"
|
||||
_STRIP_RULE = "url-base-strip"
|
||||
|
||||
_SCAN_PREFIXES = ("roles/", "group_vars/")
|
||||
_ADDON_DIRS = ("/meta/addons/", "/tasks/addons/")
|
||||
_INSTANCE_KEYS = ("oauth_instance_url", "openproject_instance_url", "url")
|
||||
_CONFIGKEY = re.compile(r"""configkey:\s*['"]?(?P<name>[A-Za-z0-9_.]+)""")
|
||||
_URL_BASE = r"""lookup\(\s*['"]tls['"]\s*,[^)]*['"]url\.base['"]\s*\)"""
|
||||
_DIRECT = re.compile(_URL_BASE + r"""\s*(?:\}\}/|~\s*['"]/)""")
|
||||
_STRIP = (
|
||||
r"""(?:\s*\))*\s*(?:\|\s*(?:regex_replace\(\s*['"]/\+?\$['"]|trim\(\s*['"]/['"])"""
|
||||
r"""|\.rstrip\(\s*['"]/['"]\s*\))"""
|
||||
)
|
||||
_DIRECT_STRIP = re.compile(_URL_BASE + _STRIP)
|
||||
_BARE_VAR = re.compile(
|
||||
r"""^\s*(?:-\s+)?(?P<name>[A-Za-z_][A-Za-z0-9_]*):\s*['"]?\{\{\s*"""
|
||||
+ _URL_BASE
|
||||
+ r"""(?:\s*\|\s*trim)?\s*\}\}['"]?\s*$"""
|
||||
)
|
||||
_BARE_SET = re.compile(
|
||||
r"""\{%-?\s*set\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*"""
|
||||
+ _URL_BASE
|
||||
+ r"""(?:\s*\|\s*trim)?\s*-?%\}"""
|
||||
)
|
||||
|
||||
|
||||
def _bare_url_base_names(files: list[tuple[str, list[str]]]) -> set[str]:
|
||||
"""Return every variable name defined as a bare url.base lookup.
|
||||
|
||||
Args:
|
||||
files: (relative path, lines) of every scanned file.
|
||||
|
||||
Returns:
|
||||
names whose value is ``lookup('tls', ..., 'url.base')`` with at most a
|
||||
``trim``, the value that always ends in "/".
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for _, lines in files:
|
||||
for line in lines:
|
||||
for pattern in (_BARE_VAR, _BARE_SET):
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
names.add(match.group("name"))
|
||||
return names
|
||||
|
||||
|
||||
def _double_slash_lines(lines: list[str], names: set[str]) -> list[int]:
|
||||
"""Return the 1-based lines that append "/" to a url.base value.
|
||||
|
||||
Args:
|
||||
lines: the file's lines.
|
||||
names: variables that hold a bare url.base.
|
||||
|
||||
Returns:
|
||||
lines where url.base, directly or through one of ``names``, is followed
|
||||
by "}}/" or "~ '/".
|
||||
"""
|
||||
via_name = (
|
||||
re.compile(
|
||||
r"""\{\{\s*(?:"""
|
||||
+ "|".join(sorted(map(re.escape, names)))
|
||||
+ r""")\s*(?:\}\}/|~\s*['"]/)"""
|
||||
)
|
||||
if names
|
||||
else None
|
||||
)
|
||||
return [
|
||||
index + 1
|
||||
for index, line in enumerate(lines)
|
||||
if _DIRECT.search(line) or (via_name and via_name.search(line))
|
||||
]
|
||||
|
||||
|
||||
def _strip_lines(lines: list[str], names: set[str]) -> list[int]:
|
||||
"""Return the 1-based lines that strip the trailing "/" from url.base.
|
||||
|
||||
Args:
|
||||
lines: the file's lines.
|
||||
names: variables that hold a bare url.base.
|
||||
|
||||
Returns:
|
||||
lines where url.base, directly or through one of ``names``, goes through
|
||||
``regex_replace('/$', '')``, ``regex_replace('/+$', '')``, ``trim('/')``
|
||||
or ``.rstrip('/')``.
|
||||
"""
|
||||
via_name = (
|
||||
re.compile(
|
||||
r"""\b(?:""" + "|".join(sorted(map(re.escape, names))) + r""")\b""" + _STRIP
|
||||
)
|
||||
if names
|
||||
else None
|
||||
)
|
||||
return [
|
||||
index + 1
|
||||
for index, line in enumerate(lines)
|
||||
if _DIRECT_STRIP.search(line) or (via_name and via_name.search(line))
|
||||
]
|
||||
|
||||
|
||||
def _addon_bare_value_lines(lines: list[str], names: set[str]) -> list[int]:
|
||||
"""Return the 1-based lines that hand an integration app a bare url.base.
|
||||
|
||||
Args:
|
||||
lines: the addon file's lines.
|
||||
names: variables that hold a bare url.base.
|
||||
|
||||
Returns:
|
||||
lines whose whole value is url.base or one of ``names`` and that are a
|
||||
``configvalue`` of one of ``_INSTANCE_KEYS``, a ``url`` key, or a
|
||||
``*_base_url`` fact.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"""^\s*(?:-\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*):\s*['"]?\{\{\s*(?:"""
|
||||
+ _URL_BASE
|
||||
+ (("|" + "|".join(sorted(map(re.escape, names)))) if names else "")
|
||||
+ r""")(?:\s*\|\s*trim)?\s*\}\}['"]?\s*$"""
|
||||
)
|
||||
found: list[int] = []
|
||||
configkey = ""
|
||||
for index, line in enumerate(lines):
|
||||
key = _CONFIGKEY.search(line)
|
||||
if key:
|
||||
configkey = key.group("name")
|
||||
match = pattern.search(line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group("key")
|
||||
if (
|
||||
(name == "configvalue" and configkey in _INSTANCE_KEYS)
|
||||
or name == "url"
|
||||
or name.endswith("_base_url")
|
||||
):
|
||||
found.append(index + 1)
|
||||
return found
|
||||
|
||||
|
||||
def _scanned_files() -> list[tuple[str, list[str]]]:
|
||||
files: list[tuple[str, list[str]]] = []
|
||||
for path_str, content in iter_project_files_with_content(
|
||||
extensions=(".yml", ".yaml", ".j2"),
|
||||
exclude_tests=True,
|
||||
):
|
||||
rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix()
|
||||
if rel.startswith(_SCAN_PREFIXES):
|
||||
files.append((rel, content.splitlines()))
|
||||
return files
|
||||
|
||||
|
||||
class TestUrlBaseNoDoubleSlash(unittest.TestCase):
|
||||
def test_addon_settings_hold_partner_urls_without_trailing_slash(self) -> None:
|
||||
files = _scanned_files()
|
||||
names = _bare_url_base_names(files)
|
||||
findings = [
|
||||
f"- {rel}:{line_no}: {lines[line_no - 1].strip()}"
|
||||
for rel, lines in files
|
||||
if any(part in rel for part in _ADDON_DIRS)
|
||||
for line_no in _addon_bare_value_lines(lines, names)
|
||||
if not is_suppressed_at(lines, line_no, _ADDON_RULE, mode="same-or-above")
|
||||
]
|
||||
|
||||
self.assertFalse(
|
||||
findings,
|
||||
"An addon setting holds lookup('tls', ..., 'url.base') as is, with "
|
||||
"its trailing '/'. Integration apps append their own paths to it "
|
||||
"(integration_openproject builds '<url>//oauth/token'), so store "
|
||||
"lookup('tls', ..., 'url.origin') instead. Mark a setting that needs "
|
||||
f"the slash with `# nocheck: {_ADDON_RULE}`.\n\nOffenders:\n"
|
||||
+ "\n".join(findings),
|
||||
)
|
||||
|
||||
def test_no_path_is_appended_to_url_base_with_a_leading_slash(self) -> None:
|
||||
files = _scanned_files()
|
||||
names = _bare_url_base_names(files)
|
||||
findings = [
|
||||
f"- {rel}:{line_no}: {lines[line_no - 1].strip()}"
|
||||
for rel, lines in files
|
||||
for line_no in _double_slash_lines(lines, names)
|
||||
if not is_suppressed_at(lines, line_no, _RULE, mode="same-or-above")
|
||||
]
|
||||
|
||||
self.assertFalse(
|
||||
findings,
|
||||
"lookup('tls', ..., 'url.base') ends in '/', so appending '/path' "
|
||||
"yields 'https://host//path', which exact-match OAuth redirect "
|
||||
"URIs and token endpoints reject. Append the path to "
|
||||
"lookup('tls', ..., 'url.origin'), join with `[base, 'path'] | "
|
||||
"url_join`, or append the path without its leading slash. Mark a "
|
||||
f"deliberate exception with `# nocheck: {_RULE}`.\n\nOffenders:\n"
|
||||
+ "\n".join(findings),
|
||||
)
|
||||
|
||||
def test_url_base_is_never_stripped_of_its_trailing_slash(self) -> None:
|
||||
files = _scanned_files()
|
||||
names = _bare_url_base_names(files)
|
||||
findings = [
|
||||
f"- {rel}:{line_no}: {lines[line_no - 1].strip()}"
|
||||
for rel, lines in files
|
||||
for line_no in _strip_lines(lines, names)
|
||||
if not is_suppressed_at(lines, line_no, _STRIP_RULE, mode="same-or-above")
|
||||
]
|
||||
|
||||
self.assertFalse(
|
||||
findings,
|
||||
"url.base is stripped of its trailing '/'. The tls lookup already "
|
||||
"returns that value as lookup('tls', ..., 'url.origin'), so read it "
|
||||
"there instead of rebuilding it per call site. Mark a deliberate "
|
||||
f"exception with `# nocheck: {_STRIP_RULE}`.\n\nOffenders:\n"
|
||||
+ "\n".join(findings),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -85,6 +85,16 @@ class TestTlsResolveLookup(unittest.TestCase):
|
||||
)[0]
|
||||
self.assertEqual(val, "https://a.example/")
|
||||
|
||||
def test_url_origin_is_the_base_url_without_its_trailing_slash(self):
|
||||
origin, base = (
|
||||
self.lookup.run(["web-app-a", want], variables=self.base_vars, mode="app")[
|
||||
0
|
||||
]
|
||||
for want in ("url.origin", "url.base")
|
||||
)
|
||||
self.assertEqual(origin, "https://a.example")
|
||||
self.assertEqual(f"{origin}/", base)
|
||||
|
||||
def test_protocols_web_path_is_http_for_an_onion_only_app(self):
|
||||
onion = dict(self.base_vars)
|
||||
onion["domains"] = {**self.domains, "web-app-onion": "abc123.onion"}
|
||||
|
||||
@@ -48,7 +48,8 @@ PANEL = ROLE / "files" / "javascript" / "logout-panel.js"
|
||||
ASSEMBLY = ROLE / "templates" / "logout-panel.js.j2"
|
||||
CATALOGUE_FILE = ROLE / "files" / "logout_i18n.yml"
|
||||
VARS = ROLE / ROLE_FILE_VARS_MAIN
|
||||
FRAMED_ORIGIN_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.base')"
|
||||
FRAMED_URL_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.base')"
|
||||
PANEL_ORIGIN_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.origin')"
|
||||
ORIGIN = "https://logout.example.test"
|
||||
DOMAINS = ("https://shop.example.test", "https://cloud.example.test")
|
||||
|
||||
@@ -356,15 +357,16 @@ class TestPanelTrustsTheFramedOrigin(unittest.TestCase):
|
||||
declaration, f"KEYCLOAK_FRONTCHANNEL_LOGOUT_URL missing from {VARS}"
|
||||
)
|
||||
self.assertIn(
|
||||
FRAMED_ORIGIN_LOOKUP,
|
||||
FRAMED_URL_LOOKUP,
|
||||
declaration,
|
||||
"the front-channel logout URL decides which origin ends up framed",
|
||||
)
|
||||
self.assertIn(
|
||||
FRAMED_ORIGIN_LOOKUP,
|
||||
PANEL_ORIGIN_LOOKUP,
|
||||
read_text(str(ASSEMBLY)),
|
||||
"the panel discards messages from any other origin, so its constant and"
|
||||
" the framed URL must come from one expression or the panel goes silent",
|
||||
"the panel discards messages from any other origin, so its constant must"
|
||||
" be the origin of the same tls resolution that builds the framed URL,"
|
||||
" or the panel goes silent",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user