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. &lt;g:localeSelect name="myLocale" value="${locale}" /&gt;
+     * <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 &mdash; 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&#8209;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. &lt;g:localeSelect name="myLocale" value="${locale}" 
labelType="autonym" /&gt;
      *
-     * @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&#8209;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. &lt;g:localeSelect name="myLocale" value="${locale}" /&gt;
+     * <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 &mdash; 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&#8209;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. &lt;g:localeSelect name="myLocale" value="${locale}" 
labelType="autonym" /&gt;
      *
-     * @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&#8209;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]

Reply via email to