jdaugherty commented on code in PR #15987:
URL: https://github.com/apache/grails-core/pull/15987#discussion_r3608770981
##########
grails-spring-security/ui/plugin/grails-app/taglib/grails/plugin/springsecurity/ui/SecurityUiTagLib.groovy:
##########
@@ -705,59 +700,59 @@ class SecurityUiTagLib {
}
def out = getOut()
- out << """<a id="$elementId" """
+ out << """<button type="submit" id="$elementId" class="btn
btn-primary" """
Review Comment:
`$elementId`, `$text` and the remaining attributes appended by
`writeRemainingAttributes` (line 885) are interpolated into the markup without
HTML/attribute encoding. Every current caller passes fixed ids and
message-bundle text, so nothing is exploitable today, but the rewrite funnels
all `s2ui:submitButton`/`s2ui:linkButton` output through this path — a future
caller passing user-derived `text` or attribute values would inject markup.
While these tags are being reworked anyway, encoding the interpolated values
(`encodeAsHTML()`) would close that door.
##########
grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy:
##########
@@ -963,39 +965,145 @@ class FormTagLib implements ApplicationContextAware,
InitializingBean, TagLibrar
}
/**
- * A helper tag for creating locale selects.<br/>
+ * A helper tag for locale selection.<br/>
*
- * eg. <g:localeSelect name="myLocale" value="${locale}" />
+ * <p>With no body it renders a control: a native {@code <select>} by
default, or a plain list of
+ * {@code <a>} links with {@code type="links"}. With a body it becomes an
iterating tag — it
+ * resolves the locales once and renders the body for each, exposing a
per-locale model under the
+ * {@code var} attribute so the caller supplies its own markup (a
Bootstrap dropdown, a footer
+ * list, etc.). The model exposes: {@code locale}, {@code tag}
(BCP‑47), {@code code}
+ * ({@code language_COUNTRY}), {@code autonym} (the name in its own
language), {@code name} (the
+ * name in the display locale), {@code label}, {@code active} (matches the
current locale),
+ * {@code default} (matches the configured default) and {@code index}.
*
- * @emptyTag
+ * eg. <g:localeSelect name="myLocale" value="${locale}"
labelType="autonym" />
*
- * @attr name REQUIRED The name of the select
- * @attr value The set locale, defaults to the current request locale if
not specified
- * @attr locale The locale to use for formatting the locale names.
Defaults to the current request locale and then the system default locale if
not specified
+ * @attr name The name of the select (select mode)
+ * @attr value The selected locale, defaults to the current request locale
if not specified
* @attr available If <code>true</code>, list only the locales the
application is translated into
* (those with a <code>messages_*.properties</code> bundle, as published
to the servlet context by
* the i18n plugin) instead of every locale the JVM knows about. Defaults
to <code>false</code>.
+ * @attr type <code>select</code> (default) or <code>links</code>. Ignored
when a body is supplied.
+ * @attr labelType The option/link label: <code>autonym</code> (each
locale in its own language),
+ * <code>name</code> (in the display locale), <code>both</code>, or
omitted for the legacy
+ * <code>"language, [COUNTRY,] name"</code> label.
+ * @attr sort If <code>true</code>, order the locales by their label using
a locale-independent collator.
+ * @attr tags If <code>true</code>, option/link keys are BCP‑47
language tags (<code>en-US</code>)
+ * rather than the legacy <code>en_US</code> form.
+ * @attr pinDefault If <code>true</code> (body mode), the configured
default locale is emitted first.
+ * @attr param The request parameter name used for <code>links</code>-mode
hrefs. Defaults to <code>lang</code>.
+ * @attr var Enables body mode: the name of the per-locale model variable
exposed to the body.
*/
- def localeSelect(Map attrs) {
- def availableAttr = attrs.remove('available')
- boolean availableOnly = availableAttr != null &&
Boolean.valueOf(availableAttr.toString())
+ def localeSelect(Map attrs, Closure body) {
+ boolean availableOnly =
Boolean.valueOf(attrs.remove('available')?.toString())
+ List locales
if (availableOnly) {
- def availableLocales =
request.servletContext?.getAttribute('availableLocales')
- attrs.from = availableLocales ?: [RCU.getLocale(request)]
+ def published =
request.servletContext?.getAttribute('availableLocales')
+ locales = published ? new ArrayList(published) :
[RCU.getLocale(request)]
}
else {
- attrs.from = Locale.getAvailableLocales()
+ locales = Locale.getAvailableLocales() as List
}
- attrs.value = (attrs.value ?: RCU.getLocale(request))?.toString()
- // set the key as a closure that formats the locale
- attrs.optionKey = { it.country ? "${it.language}_${it.country}" :
it.language }
- // set the option value as a closure that formats the locale for
display
- attrs.optionValue = { it.country ? "${it.language}, ${it.country},
${it.displayName}" : "${it.language}, ${it.displayName}" }
- // use generic select
+ Locale current = RCU.getLocale(request)
+ def valueAttr = attrs.value
+ if (valueAttr instanceof Locale) {
+ current = valueAttr
+ }
+ else if (valueAttr) {
+ current = StringUtils.parseLocale(valueAttr.toString()) ?: current
+ }
+
+ boolean useTags = Boolean.valueOf(attrs.remove('tags')?.toString())
+ Closure label = localeLabel(attrs.remove('labelType'), current)
+
+ if (Boolean.valueOf(attrs.remove('sort')?.toString())) {
+ Collator collator = Collator.getInstance(Locale.ROOT)
+ locales = locales.sort(false) { a, b ->
collator.compare(label(a).toString(), label(b).toString()) }
+ }
+
+ String varName = attrs.remove('var')
+ if (varName) {
+ boolean pinDefault =
Boolean.valueOf(attrs.remove('pinDefault')?.toString())
+ Locale defaultLocale = configuredDefaultLocale()
+ if (pinDefault) {
+ Locale pinned = locales.find { it.language ==
defaultLocale.language }
+ if (pinned) {
+ locales = [pinned] + locales.findAll { it.language !=
defaultLocale.language }
+ }
+ }
+ // A country-qualified request locale (e.g. Accept-Language:
en-US) must still mark the
+ // language-only entry (en) active when the list offers no exact
country match.
+ boolean exactActiveMatch = locales.any { it.language ==
current.language && it.country == current.country }
+ locales.eachWithIndex { locale, i ->
+ out << body([(varName): [
+ locale: locale,
+ tag: locale.toLanguageTag(),
+ code: localeKey(locale),
+ autonym: locale.getDisplayName(locale),
+ name: locale.getDisplayName(current),
+ label: label(locale),
+ active: exactActiveMatch ?
Review Comment:
When the list offers no exact country match, this fallback marks *every*
locale sharing the language active, not just the language-only entry the
comment describes. The create-app bundles ship two country variants of the same
language with no bare-language bundle (`pt_BR`/`pt_PT`, `zh_CN`/`zh_TW`), so a
request locale of bare `pt`, bare `zh`, or an unlisted variant like `zh-HK`
flags both variants active — and the welcome dropdown then highlights two rows
at once. `LocaleSelectRenderingSpec` exercises the fallback only against
single-variant lists (`[en, it]`), so this case is uncovered. Consider limiting
the fallback to a single winner: the language-only entry when the list has one,
otherwise the first language match.
##########
grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy:
##########
@@ -963,39 +965,145 @@ class FormTagLib implements ApplicationContextAware,
InitializingBean, TagLibrar
}
/**
- * A helper tag for creating locale selects.<br/>
+ * A helper tag for locale selection.<br/>
*
- * eg. <g:localeSelect name="myLocale" value="${locale}" />
+ * <p>With no body it renders a control: a native {@code <select>} by
default, or a plain list of
+ * {@code <a>} links with {@code type="links"}. With a body it becomes an
iterating tag — it
+ * resolves the locales once and renders the body for each, exposing a
per-locale model under the
+ * {@code var} attribute so the caller supplies its own markup (a
Bootstrap dropdown, a footer
+ * list, etc.). The model exposes: {@code locale}, {@code tag}
(BCP‑47), {@code code}
+ * ({@code language_COUNTRY}), {@code autonym} (the name in its own
language), {@code name} (the
+ * name in the display locale), {@code label}, {@code active} (matches the
current locale),
+ * {@code default} (matches the configured default) and {@code index}.
*
- * @emptyTag
+ * eg. <g:localeSelect name="myLocale" value="${locale}"
labelType="autonym" />
*
- * @attr name REQUIRED The name of the select
- * @attr value The set locale, defaults to the current request locale if
not specified
- * @attr locale The locale to use for formatting the locale names.
Defaults to the current request locale and then the system default locale if
not specified
+ * @attr name The name of the select (select mode)
+ * @attr value The selected locale, defaults to the current request locale
if not specified
* @attr available If <code>true</code>, list only the locales the
application is translated into
* (those with a <code>messages_*.properties</code> bundle, as published
to the servlet context by
* the i18n plugin) instead of every locale the JVM knows about. Defaults
to <code>false</code>.
+ * @attr type <code>select</code> (default) or <code>links</code>. Ignored
when a body is supplied.
+ * @attr labelType The option/link label: <code>autonym</code> (each
locale in its own language),
+ * <code>name</code> (in the display locale), <code>both</code>, or
omitted for the legacy
+ * <code>"language, [COUNTRY,] name"</code> label.
+ * @attr sort If <code>true</code>, order the locales by their label using
a locale-independent collator.
+ * @attr tags If <code>true</code>, option/link keys are BCP‑47
language tags (<code>en-US</code>)
+ * rather than the legacy <code>en_US</code> form.
+ * @attr pinDefault If <code>true</code> (body mode), the configured
default locale is emitted first.
+ * @attr param The request parameter name used for <code>links</code>-mode
hrefs. Defaults to <code>lang</code>.
+ * @attr var Enables body mode: the name of the per-locale model variable
exposed to the body.
*/
- def localeSelect(Map attrs) {
- def availableAttr = attrs.remove('available')
- boolean availableOnly = availableAttr != null &&
Boolean.valueOf(availableAttr.toString())
+ def localeSelect(Map attrs, Closure body) {
+ boolean availableOnly =
Boolean.valueOf(attrs.remove('available')?.toString())
+ List locales
if (availableOnly) {
- def availableLocales =
request.servletContext?.getAttribute('availableLocales')
- attrs.from = availableLocales ?: [RCU.getLocale(request)]
+ def published =
request.servletContext?.getAttribute('availableLocales')
+ locales = published ? new ArrayList(published) :
[RCU.getLocale(request)]
}
else {
- attrs.from = Locale.getAvailableLocales()
+ locales = Locale.getAvailableLocales() as List
}
- attrs.value = (attrs.value ?: RCU.getLocale(request))?.toString()
- // set the key as a closure that formats the locale
- attrs.optionKey = { it.country ? "${it.language}_${it.country}" :
it.language }
- // set the option value as a closure that formats the locale for
display
- attrs.optionValue = { it.country ? "${it.language}, ${it.country},
${it.displayName}" : "${it.language}, ${it.displayName}" }
- // use generic select
+ Locale current = RCU.getLocale(request)
+ def valueAttr = attrs.value
+ if (valueAttr instanceof Locale) {
+ current = valueAttr
+ }
+ else if (valueAttr) {
+ current = StringUtils.parseLocale(valueAttr.toString()) ?: current
+ }
+
+ boolean useTags = Boolean.valueOf(attrs.remove('tags')?.toString())
+ Closure label = localeLabel(attrs.remove('labelType'), current)
+
+ if (Boolean.valueOf(attrs.remove('sort')?.toString())) {
+ Collator collator = Collator.getInstance(Locale.ROOT)
+ locales = locales.sort(false) { a, b ->
collator.compare(label(a).toString(), label(b).toString()) }
+ }
+
+ String varName = attrs.remove('var')
+ if (varName) {
+ boolean pinDefault =
Boolean.valueOf(attrs.remove('pinDefault')?.toString())
+ Locale defaultLocale = configuredDefaultLocale()
+ if (pinDefault) {
+ Locale pinned = locales.find { it.language ==
defaultLocale.language }
+ if (pinned) {
+ locales = [pinned] + locales.findAll { it.language !=
defaultLocale.language }
Review Comment:
`findAll { it.language != defaultLocale.language }` removes every locale
that shares the default's *language*, not just the pinned entry — and the pin
itself matches by language only. With the bundle set the create-app ships,
configuring `grails.i18n.default.locale=pt_BR` pins whichever `pt` variant the
list yields first and silently drops the other (`pt_PT`) from the selector
entirely; the `zh_CN`/`zh_TW` pair behaves the same. Pinning the exact locale
(with a language-only fallback) and keeping the remainder — `locales = [pinned]
+ (locales - pinned)` — preserves every entry while still emitting the default
first.
##########
grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp:
##########
@@ -281,26 +371,784 @@
</div>
</div>
</g:link>
+ </g:if>
+ <g:else>
+ <form
action="${controllerUrl}" method="post" class="m-0 d-flex align-items-center
gap-3 min-w-0 flex-grow-1">
+ <button type="submit"
class="btn p-0 border-0 d-flex align-items-center gap-3 text-start min-w-0
w-100">
+ <span class="min-w-0">
+ <span
class="fw-semibold text-body text-truncate d-block">
+ ${simpleName}
+ </span>
+ </span>
+ </button>
+ </form>
+ </g:else>
- <a href="${controllerUrl}"
- class="small link-primary
link-offset-2 link-underline-opacity-0 link-underline-opacity-75-hover
flex-shrink-0">
- ${controllerUrl}
- </a>
+ <div class="d-flex
align-items-center gap-2 flex-shrink-0 ms-auto">
+ <g:if test="${hasShow}">
+ <%-- Falls back to GET
/controller/show?id=… when JS is
+ off; welcome.js
upgrades it to the /show/{id} path. --%>
+ <form class="show-jump
d-lg-none" action="${showBase}" method="get" data-show-base="${showBase}">
+ <%-- No inputmode:
ids may be Long, String, or ObjectId, and a
+ numeric
keypad would lock out non-digit ids on mobile. --%>
+ <input type="text"
name="id" autocomplete="off"
+
class="form-control form-control-sm show-jump-input"
+
placeholder="${message(code: 'welcome.show.placeholder')}"
+
data-focus-placeholder="${message(code: 'welcome.show.hint')}"
+
aria-label="${message(code: 'welcome.show.aria', args: [simpleName])}">
+ <button
type="submit" class="btn btn-sm btn-primary show-jump-go">
+ <g:message
code="welcome.show.label"/>
+ </button>
+ </form>
+ </g:if>
+
+ <g:if test="${ctrlGetOk}">
+ <a href="${controllerUrl}"
+ class="small
link-primary link-offset-2 link-underline-opacity-0
link-underline-opacity-75-hover">
+ ${controllerUrl}
+ </a>
+ </g:if>
+ <g:else>
+ <span class="badge
bg-body-tertiary text-body-secondary border">
+
${[ctrlMethods].flatten()*.toString()*.toUpperCase().join(' / ')}
+ </span>
+ <span class="small
text-body-secondary">${controllerUrl}</span>
+ </g:else>
+ </div>
</div>
</li>
</g:each>
</ul>
</div>
</g:each>
</div>
- <p id="controllers-empty" class="small
text-body-secondary d-none mb-0"><g:message code="welcome.filter.none"/></p>
+ <g:if test="${numControllers != 0}">
+ <p id="controllers-empty" class="small
text-body-secondary d-none mb-0"><g:message code="welcome.filter.none"/></p>
+ </g:if>
+ <g:else>
+ <p class="small text-body-secondary
mb-0"><g:message code="welcome.artefacts.none"/></p>
+ </g:else>
+ </div>
+
+ <div data-switch-for="domains" class="d-none">
+ <div id="domains-list">
+ <ul class="list-group list-group-flush">
+ <g:each var="d"
in="${grailsApplication.domainClasses.toList().sort { it.shortName }}">
+ <li class="list-group-item px-2 d-flex
align-items-center justify-content-between gap-2" data-name="${d.shortName}">
+ <span class="fw-semibold text-body
text-truncate">${d.shortName}</span>
+ <span class="small text-body-secondary
text-truncate">${d.packageName}</span>
+ </li>
+ </g:each>
+ </ul>
+ </div>
+ <g:if test="${numDomains != 0}">
+ <p id="domains-empty" class="small
text-body-secondary d-none mb-0"><g:message code="welcome.filter.none"/></p>
+ </g:if>
+ <g:else>
+ <p class="small text-body-secondary
mb-0"><g:message code="welcome.artefacts.none"/></p>
+ </g:else>
+ </div>
+
+ <div data-switch-for="services" class="d-none">
+ <div id="services-list">
+ <ul class="list-group list-group-flush">
+ <g:each var="s"
in="${grailsApplication.serviceClasses.toList().sort { it.shortName }}">
+ <li class="list-group-item px-2 d-flex
align-items-center justify-content-between gap-2" data-name="${s.shortName}">
+ <span class="fw-semibold text-body
text-truncate">${s.shortName}</span>
+ <span class="small text-body-secondary
text-truncate">${s.packageName}</span>
+ </li>
+ </g:each>
+ </ul>
+ </div>
+ <g:if test="${numServices != 0}">
+ <p id="services-empty" class="small
text-body-secondary d-none mb-0"><g:message code="welcome.filter.none"/></p>
+ </g:if>
+ <g:else>
+ <p class="small text-body-secondary
mb-0"><g:message code="welcome.artefacts.none"/></p>
+ </g:else>
+ </div>
+
+ <div data-switch-for="taglibs" class="d-none">
+ <div id="taglibs-list">
+ <ul class="list-group list-group-flush">
+ <g:each var="t"
in="${grailsApplication.tagLibClasses.toList().sort { it.shortName }}">
+ <li class="list-group-item px-2 d-flex
align-items-center justify-content-between gap-2" data-name="${t.shortName}">
+ <span class="fw-semibold text-body
text-truncate">${t.shortName}</span>
+ <span class="small
text-truncate"><code>${t.namespace}</code> <span
class="text-body-secondary">${t.packageName}</span></span>
+ </li>
+ </g:each>
+ </ul>
+ </div>
+ <g:if test="${numTagLibs != 0}">
+ <p id="taglibs-empty" class="small
text-body-secondary d-none mb-0"><g:message code="welcome.filter.none"/></p>
+ </g:if>
+ <g:else>
+ <p class="small text-body-secondary
mb-0"><g:message code="welcome.artefacts.none"/></p>
+ </g:else>
+ </div>
+ </div>
+ </div>
+
+ <%-- RUNTIME INTERNALS: listeners, data binding, mime handling
and
+ the request filter pipeline resolved from the running
application
+ context; reuses the artefacts card's data-switch-scope
pattern. --%>
+ <g:set var="appListeners"
+
value="${applicationContext.applicationListeners.toList()
+ .collect { l -> [name: (l.getClass().simpleName
?: l.getClass().name.tokenize('.').last()),
+ packageName:
(l.getClass().package?.name ?: ''),
+ detail: l.toString()] }
+ .sort { a, b -> (a.name.toLowerCase() <=>
b.name.toLowerCase()) ?: (a.detail <=> b.detail) }}"/>
+ <g:set var="bindingGroups"
+ value="${[[code: 'welcome.binding.value', beans:
applicationContext.getBeansOfType(grails.databinding.converters.ValueConverter)],
+ [code: 'welcome.binding.formatted', beans:
applicationContext.getBeansOfType(grails.databinding.converters.FormattedValueConverter)],
+ [code: 'welcome.binding.structured', beans:
applicationContext.getBeansOfType(grails.databinding.TypedStructuredBindingEditor)],
+ [code: 'welcome.binding.listeners', beans:
applicationContext.getBeansOfType(grails.databinding.events.DataBindingListener)]]}"/>
+ <g:set var="numBindingBeans" value="${bindingGroups.sum { g ->
g.beans.size() } ?: 0}"/>
+ <g:set var="mimeTypeProviders"
+
value="${applicationContext.getBeansOfType(grails.web.mime.MimeTypeProvider)
+ .entrySet().toList().sort {
it.key.toLowerCase() }}"/>
+ <%-- The filters still on the call stack ARE this request's
pipeline, in
+ execution order: walk the reversed stack, keep Filter
classes, collapse
+ the extra frames a filter contributes through its
abstract bases, and
+ number what remains. No registry can report this actual
order. --%>
+ <g:set var="requestFilters"
+
value="${Thread.currentThread().stackTrace.toList().reverse()
+ .findResults { ste ->
+ def cls = null
+ try { cls = Class.forName(ste.className,
false, Thread.currentThread().contextClassLoader) } catch (Throwable ignored) {
}
+ (cls != null &&
jakarta.servlet.Filter.isAssignableFrom(cls)) ? cls : null
+ }
+ .inject([]) { acc, cls ->
+ def prev = acc ? acc[-1] : null
+ if (prev == cls) { return acc }
+ if (prev != null &&
prev.isAssignableFrom(cls)) { acc[-1] = cls; return acc }
+ if (prev != null &&
cls.isAssignableFrom(prev)) { return acc }
+ acc << cls
+ }
+ .unique()}"/>
+ <g:set var="filterRegistrations"
+
value="${applicationContext.getBeansOfType(org.springframework.boot.web.servlet.FilterRegistrationBean)
+ .entrySet().toList().sort { it.value.order }}"/>
+ <g:set var="filterChainProxyType"
+
value="${ClassUtils.isPresent('org.springframework.security.web.FilterChainProxy',
null) ?
ClassUtils.forName('org.springframework.security.web.FilterChainProxy', null) :
null}"/>
+ <g:set var="securityFilterChains"
Review Comment:
The runtime-internals surface renders in every environment — `Environment`
is referenced on this page only for display. That includes each security filter
chain's request matcher (here), the effective servlet filter pipeline, listener
and internal bean names, and the server hostname/OS further up. The previous
welcome page also rendered in production, but it exposed far less than this.
Since this is the generated app's default `/` view and frequently survives into
production unchanged, consider gating the internals cards (or the whole
diagnostic block) on `Environment.current == Environment.DEVELOPMENT`. The same
applies to the byte-identical copy at
`grails-profiles/web/skeleton/grails-app/views/index.gsp`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]