From 764819f04c0c30a884257ba4c1625db9ec898900 Mon Sep 17 00:00:00 2001 From: Nick Kerr Date: Thu, 15 Jan 2026 15:29:57 -0800 Subject: [PATCH 01/11] Fix filter for data/material input (#7322) --- .../org/labkey/experiment/api/ExpProtocolApplicationImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/experiment/src/org/labkey/experiment/api/ExpProtocolApplicationImpl.java b/experiment/src/org/labkey/experiment/api/ExpProtocolApplicationImpl.java index 56c1203b45f..9b2a3693d88 100644 --- a/experiment/src/org/labkey/experiment/api/ExpProtocolApplicationImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpProtocolApplicationImpl.java @@ -397,7 +397,7 @@ private long deleteDataInputs() OntologyManager.deleteOntologyObjects(svc.getSchema(), new SQLFragment("SELECT " + dialect.concatenate("'" + DataInput.lsidPrefix() + "'", "CAST(dataId AS VARCHAR)", "'.'", "CAST(targetApplicationId AS VARCHAR)") + - " FROM " + svc.getTinfoDataInput() + " WHERE TargetApplicationId IN (SELECT RowId FROM exp.ProtocolApplication WHERE RunId = " + getRowId() + ")"), getContainer()); + " FROM " + svc.getTinfoDataInput() + " WHERE TargetApplicationId = ?", getRowId()), getContainer()); return Table.delete(ExperimentServiceImpl.get().getTinfoDataInput(), new SimpleFilter(FieldKey.fromParts("TargetApplicationId"), getRowId())); } @@ -413,7 +413,7 @@ private long deleteMaterialInputs() OntologyManager.deleteOntologyObjects(svc.getSchema(), new SQLFragment("SELECT " + dialect.concatenate("'" + MaterialInput.lsidPrefix() + "'", "CAST(materialId AS VARCHAR)", "'.'", "CAST(targetApplicationId AS VARCHAR)") + - " FROM " + svc.getTinfoMaterialInput() + " WHERE TargetApplicationId IN (SELECT RowId FROM exp.ProtocolApplication WHERE RunId = " + getRowId() + ")"), getContainer()); + " FROM " + svc.getTinfoMaterialInput() + " WHERE TargetApplicationId = ?", getRowId()), getContainer()); return Table.delete(ExperimentServiceImpl.get().getTinfoMaterialInput(), new SimpleFilter(FieldKey.fromParts("TargetApplicationId"), getRowId())); } From a16ff911af7d608ec20b37079937eea99d6e9759 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Fri, 30 Jan 2026 09:32:10 -0800 Subject: [PATCH 02/11] Expose register action via LoginUrls (#7372) --- api/src/org/labkey/api/security/LoginUrls.java | 1 + core/src/org/labkey/core/login/LoginController.java | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/api/src/org/labkey/api/security/LoginUrls.java b/api/src/org/labkey/api/security/LoginUrls.java index 19654e471e1..3a7652fb9c0 100644 --- a/api/src/org/labkey/api/security/LoginUrls.java +++ b/api/src/org/labkey/api/security/LoginUrls.java @@ -33,6 +33,7 @@ public interface LoginUrls extends UrlProvider ActionURL getInitialUserURL(); ActionURL getLoginURL(); ActionURL getLoginURL(URLHelper returnUrl); + ActionURL getRegisterURL(Container c, @Nullable URLHelper returnUrl); ActionURL getLoginURL(Container c, @Nullable URLHelper returnUrl); ActionURL getLogoutURL(Container c); ActionURL getLogoutURL(Container c, URLHelper returnUrl); diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 2374870c935..fe5650b8808 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -239,6 +239,17 @@ public ActionURL getLoginURL(Container c, @Nullable URLHelper returnUrl) return url; } + @Override + public ActionURL getRegisterURL(Container c, @Nullable URLHelper returnUrl) + { + ActionURL url = new ActionURL(RegisterAction.class, c); + + if (null != returnUrl) + url.addReturnUrl(returnUrl); + + return url; + } + @Override public ActionURL getLogoutURL(Container c) { From 4ab0ea2261ea0873ee34bae1d54c024ab638ec3f Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Mon, 2 Feb 2026 16:47:09 -0800 Subject: [PATCH 03/11] Support report-to CSP directive and Reporting-Endpoints header (#7374) (#7380) --- .../org/labkey/api/action/BaseApiAction.java | 2 +- api/src/org/labkey/api/admin/AdminUrls.java | 1 + .../ImpersonatingTroubleshooterRole.java | 6 + api/src/org/labkey/api/util/MimeMap.java | 5 +- .../filters/ContentSecurityPolicyFilter.java | 73 ++++++- .../labkey/core/admin/AdminController.java | 183 ++++++++++++------ 6 files changed, 205 insertions(+), 65 deletions(-) diff --git a/api/src/org/labkey/api/action/BaseApiAction.java b/api/src/org/labkey/api/action/BaseApiAction.java index e4a6809cede..d0944264647 100644 --- a/api/src/org/labkey/api/action/BaseApiAction.java +++ b/api/src/org/labkey/api/action/BaseApiAction.java @@ -312,7 +312,7 @@ private FormAndErrors
populateForm() throws Exception if (null != contentType) { if (MimeMap.DEFAULT.isJsonContentTypeHeader(contentType)) - { + { _reqFormat = ApiResponseWriter.Format.JSON; return populateJsonForm(); } diff --git a/api/src/org/labkey/api/admin/AdminUrls.java b/api/src/org/labkey/api/admin/AdminUrls.java index f7a46e6ffff..4f2e30826d5 100644 --- a/api/src/org/labkey/api/admin/AdminUrls.java +++ b/api/src/org/labkey/api/admin/AdminUrls.java @@ -65,6 +65,7 @@ public interface AdminUrls extends UrlProvider ActionURL getSessionLoggingURL(); ActionURL getTrackedAllocationsViewerURL(); ActionURL getSystemMaintenanceURL(); + ActionURL getCspReportToURL(String cspVersion); /** * Simply adds an "Admin Console" link to nav trail if invoked in the root container. Otherwise, root is unchanged. diff --git a/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java b/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java index b0a8d1ffddf..f6bdfcad095 100644 --- a/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java +++ b/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java @@ -26,4 +26,10 @@ public boolean isPrivileged() { return true; } + + @Override + public boolean isAvailableEverywhere() + { + return false; + } } diff --git a/api/src/org/labkey/api/util/MimeMap.java b/api/src/org/labkey/api/util/MimeMap.java index fc04a1f0c47..f0341ee6397 100644 --- a/api/src/org/labkey/api/util/MimeMap.java +++ b/api/src/org/labkey/api/util/MimeMap.java @@ -125,13 +125,14 @@ public int hashCode() public static final MimeType XML = new MimeType("text/xml"); public static final MimeType JSON = new MimeType("application/json", false, true); public static final MimeType TEXT_JSON = new MimeType("text/json", false, true); - public static final MimeType CSP = new MimeType("application/csp-report", false, true); + public static final MimeType CSP_REPORT_URI_JSON = new MimeType("application/csp-report", false, true); + public static final MimeType CSP_REPORT_TO_JSON = new MimeType("application/reports+json", false, true); } static { for (MimeType mt : Arrays.asList(MimeType.GIF, MimeType.JPEG, MimeType.PDF, MimeType.PNG, MimeType.SVG, MimeType.HTML, MimeType.PLAIN, MimeType.XML, - MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP)) + MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP_REPORT_URI_JSON, MimeType.CSP_REPORT_TO_JSON)) { mimeTypeMap.put(mt.getContentType(), mt); } diff --git a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java index ae58e441059..71840b510a7 100644 --- a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java +++ b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java @@ -11,9 +11,12 @@ import org.apache.commons.collections4.SetValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Test; +import org.labkey.api.admin.AdminUrls; import org.labkey.api.collections.CopyOnWriteHashMap; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.security.Directive; @@ -36,6 +39,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -64,8 +68,14 @@ public class ContentSecurityPolicyFilter implements Filter // Per-filter-instance parameters that are set in init() and never changed private ContentSecurityPolicyType _type = ContentSecurityPolicyType.Enforce; - private String _policyTemplate = null; - private String _cspVersion = "Unknown"; + private @NotNull String _cspVersion = "Unknown"; + private String _stashedTemplate = null; + private String _reportToEndpointName = null; + + // Per-filter-instance parameters that are set at first request and reset if base server URL changes + private volatile String _previousBaseServerUrl = null; + private volatile String _policyTemplate = null; + private volatile String _reportingEndpointsHeaderValue = null; // Updated after every change to "allowed sources" private StringExpression _policyExpression = null; @@ -104,7 +114,6 @@ public String getHeaderName() public void init(FilterConfig filterConfig) throws ServletException { LogHelper.getLogger(ContentSecurityPolicyFilter.class, "CSP filter initialization").info("Initializing {}", filterConfig.getFilterName()); - Enumeration paramNames = filterConfig.getInitParameterNames(); while (paramNames.hasMoreElements()) { @@ -115,10 +124,9 @@ public void init(FilterConfig filterConfig) throws ServletException String s = filterPolicy(paramValue); // Replace REPORT_PARAMETER_SUBSTITUTION now since its value is static - s = StringExpressionFactory.create(s, false, NullValueBehavior.KeepSubstitution) - .eval(Map.of(REPORT_PARAMETER_SUBSTITUTION, "labkeyVersion=" + PageFlowUtil.encodeURIComponent(AppProps.getInstance().getReleaseVersion()))); + s = substituteReportParams(s); - _policyTemplate = s; + _policyTemplate = _stashedTemplate = s; extractCspVersion(s); } @@ -139,9 +147,18 @@ else if ("disposition".equalsIgnoreCase(paramName)) if (CSP_FILTERS.put(_type, this) != null) throw new ServletException("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + _type); + // configure a different endpoint for each type to convey the correct csp version (eXX vs. rXX) + _reportToEndpointName = "csp-" + _type.name().toLowerCase(); + regeneratePolicyExpression(); } + private String substituteReportParams(String expression) + { + return StringExpressionFactory.create(expression, false, NullValueBehavior.KeepSubstitution) + .eval(Map.of(REPORT_PARAMETER_SUBSTITUTION, "labkeyVersion=" + PageFlowUtil.encodeURIComponent(AppProps.getInstance().getReleaseVersion()))); + } + /** Filter out block comments and replace special characters in the provided policy */ public static String filterPolicy(String policy) { @@ -199,7 +216,8 @@ private void extractCspVersion(String s) LOG.debug("CspVersion: {}", _cspVersion); } - // Make all the "allowed sources" substitutions at init() and whenever the allowed sources map changes. With this, + // Make all the "allowed sources" substitutions at init(), whenever the allowed sources map changes, or whenever the + // policy template changes (e.g., base server URL change that causes report-to to be added or removed). With this, // the only substitution needed on a per-request basis is the nonce value. private void regeneratePolicyExpression() { @@ -219,16 +237,57 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha { if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp && null != _policyExpression) { + ensurePolicy(); + if (_type != ContentSecurityPolicyType.Enforce || !OptionalFeatureService.get().isFeatureEnabled(FEATURE_FLAG_DISABLE_ENFORCE_CSP)) { Map map = Map.of(NONCE_SUBST, getScriptNonceHeader(req)); var csp = _policyExpression.eval(map); resp.setHeader(_type.getHeaderName(), csp); + + // null if https: is not configured on this server + if (_reportingEndpointsHeaderValue != null) + resp.addHeader("Reporting-Endpoints", _reportingEndpointsHeaderValue); } } chain.doFilter(request, response); } + private void ensurePolicy() + { + String baseServerUrl = AppProps.getInstance().getBaseServerUrl(); + + // Reconsider "report-to" directive and "Reporting-Endpoints" header if base server URL has changed + if (!Objects.equals(baseServerUrl, _previousBaseServerUrl)) + { + synchronized (SUBSTITUTION_LOCK) + { + _previousBaseServerUrl = baseServerUrl; + + // Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this + // server. This ensures that browsers fall-back on report-uri if https: isn't configured. + if (Strings.CI.startsWith(baseServerUrl, "https://")) + { + // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX) + @SuppressWarnings("DataFlowIssue") + ActionURL violationUrl = PageFlowUtil.urlProvider(AdminUrls.class).getCspReportToURL(_cspVersion); + // Use an absolute URL so we always post to https:, even if the violating request uses http: + _reportingEndpointsHeaderValue = _reportToEndpointName + "=\"" + substituteReportParams(violationUrl.getURIString() + "&${CSP.REPORT.PARAMS}") + "\""; + + // Add "report-to" directive to the policy + _policyTemplate = _stashedTemplate + " report-to " + _reportToEndpointName + " ;"; + } + else + { + _reportingEndpointsHeaderValue = null; + _policyTemplate = _stashedTemplate; + } + + regeneratePolicyExpression(); + } + } + } + public static String getScriptNonceHeader(HttpServletRequest request) { String nonce = (String)request.getAttribute(HEADER_NONCE); diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index bd0ea647325..da9247a2861 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -44,6 +44,7 @@ import org.jfree.chart.JFreeChart; import org.jfree.chart.plot.PlotOrientation; import org.jfree.data.category.DefaultCategoryDataset; +import org.json.JSONArray; import org.json.JSONObject; import org.junit.Assert; import org.junit.Test; @@ -190,7 +191,6 @@ import org.labkey.api.security.AdminConsoleAction; import org.labkey.api.security.CSRF; import org.labkey.api.security.Directive; -import org.labkey.api.security.ElevatedUser; import org.labkey.api.security.Group; import org.labkey.api.security.GroupManager; import org.labkey.api.security.IgnoresTermsOfUse; @@ -883,6 +883,13 @@ public ActionURL getSystemMaintenanceURL() return new ActionURL(ConfigureSystemMaintenanceAction.class, ContainerManager.getRoot()); } + @Override + public ActionURL getCspReportToURL(@NotNull String cspVersion) + { + return new ActionURL(ContentSecurityPolicyReportToAction.class, ContainerManager.getRoot()) + .addParameter("cspVersion", cspVersion); + } + public static ActionURL getDeprecatedFeaturesURL() { return new ActionURL(OptionalFeaturesAction.class, ContainerManager.getRoot()).addParameter("type", FeatureType.Deprecated.name()); @@ -11979,16 +11986,54 @@ public void addNavTrail(NavTree root) } } - private static final URI LABKEY_ORG_REPORT_ACTION; + private static final URI LABKEY_ORG_REPORT_URI_ACTION; + private static final URI LABKEY_ORG_REPORT_TO_ACTION; static { - LABKEY_ORG_REPORT_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReport.api"); + LABKEY_ORG_REPORT_URI_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReport.api"); + LABKEY_ORG_REPORT_TO_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReportTo.api"); + } + + // report-to endpoints get sent a JSON array of reports. Use Jackson to deserialize these into a List. + public static class ReportToJsonObjects extends ArrayList + { } @RequiresNoPermission @CSRF(CSRF.Method.NONE) - public static class ContentSecurityPolicyReportAction extends ReadOnlyApiAction + @Marshal(Marshaller.Jackson) + public static class ContentSecurityPolicyReportToAction extends BaseContentSecurityPolicyReportAction + { + @Override + public void handleReports(ReportToJsonObjects jsonObjects, HttpServletRequest request, String userAgent) throws IOException, InterruptedException + { + JSONArray reportsToForward = new JSONArray(); + + jsonObjects.forEach(jsonObject -> { + if (handleOneReport(jsonObject, request, userAgent, "body", "blockedURL", "documentURL")) + reportsToForward.put(jsonObject); + }); + + if (!reportsToForward.isEmpty()) + forwardReports(LABKEY_ORG_REPORT_TO_ACTION, request, reportsToForward.toString(2)); + } + } + + @RequiresNoPermission + @CSRF(CSRF.Method.NONE) + public static class ContentSecurityPolicyReportAction extends BaseContentSecurityPolicyReportAction + { + @Override + public void handleReports(SimpleApiJsonForm form, HttpServletRequest request, String userAgent) throws IOException, InterruptedException + { + JSONObject jsonObject = form.getJsonObject(); + if (handleOneReport(jsonObject, request, userAgent, "csp-report", "blocked-uri", "document-uri")) + forwardReports(LABKEY_ORG_REPORT_URI_ACTION, request, jsonObject.toString(2)); + } + } + + protected abstract static class BaseContentSecurityPolicyReportAction extends ReadOnlyApiAction { private static final Logger _log = LogHelper.getLogger(ContentSecurityPolicyReportAction.class, "CSP warnings"); @@ -11996,7 +12041,15 @@ public static class ContentSecurityPolicyReportAction extends ReadOnlyApiAction< private static final Map reports = Collections.synchronizedMap(new LRUMap<>(20)); @Override - public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception + protected String getCommandClassMethodName() + { + return "handleReports"; + } + + abstract public void handleReports(FORM form, HttpServletRequest request, String userAgent) throws IOException, InterruptedException; + + @Override + public Object execute(FORM form, BindException errors) throws Exception { var ret = new JSONObject().put("success", true); @@ -12011,35 +12064,50 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep if (PageFlowUtil.isRobotUserAgent(userAgent) && !_log.isDebugEnabled()) return ret; - // NOTE User may be "guest", and will always be guest if being relayed to labkey.org - var jsonObj = form.getJsonObject(); + handleReports(form, request, userAgent); + + return ret; + } + + // Returns true if the report should be forwarded + protected boolean handleOneReport(JSONObject jsonObj, HttpServletRequest request, String userAgent, String bodyKey, String blockedUrlKey, String documentUrlKey) + { if (null != jsonObj) { - JSONObject cspReport = jsonObj.optJSONObject("csp-report"); + JSONObject cspReport = jsonObj.optJSONObject(bodyKey); if (cspReport != null) { - String blockedUri = cspReport.optString("blocked-uri", null); + String blockedUrl = cspReport.optString(blockedUrlKey, null); // Issue 52933 - suppress base-uri problems from a crawler or bot on labkey.org - if (blockedUri != null && - blockedUri.startsWith("https://labkey.org%2C") && - blockedUri.endsWith("undefined") && - !_log.isDebugEnabled()) + if (blockedUrl != null && + blockedUrl.startsWith("https://labkey.org%2C") && + blockedUrl.endsWith("undefined") && + !_log.isDebugEnabled()) { - return ret; + return false; } - String urlString = cspReport.optString("document-uri", null); - if (urlString != null) + String documentUrl = cspReport.optString(documentUrlKey, null); + if (documentUrl != null) { - URLHelper urlHelper = new URLHelper(urlString); + URLHelper documentUrlHelper; + try + { + documentUrlHelper = new URLHelper(documentUrl); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + // URL parameter that tells us to bypass suppression of redundant logging // Used to make sure that tests of CSP logging are deterministic and convenient - boolean bypassCspDedupe = "true".equals(urlHelper.getParameter("bypassCspDedupe")); - String path = urlHelper.deleteParameters().getURIString(); + boolean bypassCspDedupe = "true".equals(documentUrlHelper.getParameter("bypassCspDedupe")); + String path = documentUrlHelper.deleteParameters().getURIString(); if (null == reports.put(path, Boolean.TRUE) || _log.isDebugEnabled() || bypassCspDedupe) { - // Don't modify forwarded reports; they already have user, ip, user-agent, etc. from the forwarding server. + // Don't modify forwarded reports; they already have user, ip, user_agent, etc. from the forwarding server. boolean forwarded = jsonObj.optBoolean("forwarded", false); if (!forwarded) { @@ -12047,7 +12115,7 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep String email = null; // If the user is not logged in, we may still be able to snag the email address from our cookie if (user.isGuest()) - email = LoginController.getEmailFromCookie(getViewContext().getRequest()); + email = LoginController.getEmailFromCookie(request); if (null == email) email = user.getEmail(); jsonObj.put("user", email); @@ -12055,8 +12123,8 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep if (ipAddress == null) ipAddress = request.getRemoteAddr(); jsonObj.put("ip", ipAddress); - if (isNotBlank(userAgent)) - jsonObj.put("user-agent", userAgent); + if (isNotBlank(userAgent) && !jsonObj.has("user_agent")) + jsonObj.put("user_agent", userAgent); String labkeyVersion = request.getParameter("labkeyVersion"); if (null != labkeyVersion) jsonObj.put("labkeyVersion", labkeyVersion); @@ -12066,50 +12134,55 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep } var jsonStr = jsonObj.toString(2); - _log.warn("ContentSecurityPolicy warning on page: {}\n{}", urlString, jsonStr); + _log.warn("ContentSecurityPolicy warning on page: {}\n{}", documentUrl, jsonStr); - if (!forwarded && OptionalFeatureService.get().isFeatureEnabled(ContentSecurityPolicyFilter.FEATURE_FLAG_FORWARD_CSP_REPORTS)) - { + boolean shouldForward = !forwarded && OptionalFeatureService.get().isFeatureEnabled(ContentSecurityPolicyFilter.FEATURE_FLAG_FORWARD_CSP_REPORTS); + if (shouldForward) jsonObj.put("forwarded", true); - // Create an HttpClient - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); + return shouldForward; + } + } + } + } - // Create the POST request - HttpRequest remoteRequest = HttpRequest.newBuilder() - .uri(LABKEY_ORG_REPORT_ACTION) - .header("Content-Type", request.getContentType()) // Use whatever the browser set - .POST(HttpRequest.BodyPublishers.ofString(jsonObj.toString(2))) - .build(); + return false; + } + + protected void forwardReports(URI destination, HttpServletRequest request, String content) throws IOException, InterruptedException + { + // Create an HttpClient + try (HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build()) + { + // Create the POST request + HttpRequest remoteRequest = HttpRequest.newBuilder() + .uri(destination) + .header("Content-Type", request.getContentType()) // Use whatever the browser set + .POST(HttpRequest.BodyPublishers.ofString(content)) + .build(); - // Send the request and get the response - HttpResponse response = client.send(remoteRequest, HttpResponse.BodyHandlers.ofString()); + // Send the request and get the response + HttpResponse response = client.send(remoteRequest, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) - { - _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}\n{}", response.statusCode(), response.body()); - } - else - { - JSONObject jsonResponse = new JSONObject(response.body()); - boolean success = jsonResponse.optBoolean("success", false); - if (!success) - { - _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}", jsonResponse); - } - } - } - } + if (response.statusCode() != 200) + { + _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}\n{}", response.statusCode(), response.body()); + } + else + { + JSONObject jsonResponse = new JSONObject(response.body()); + boolean success = jsonResponse.optBoolean("success", false); + if (!success) + { + _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}", jsonResponse); } } } - return ret; } } - public static class TestCase extends AbstractActionPermissionTest { @Override From 5dc7ebd86204104a543c321b47a8c79af7a507c8 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Wed, 4 Feb 2026 10:55:57 -0800 Subject: [PATCH 04/11] Support non-user-visible rendering of HTML comments in Markdown (#7373) Co-authored-by: Adam Rauch --- .../labkey/core/wiki/MarkdownServiceImpl.java | 60 +++++++++++++++++++ .../labkey/core/wiki/MarkdownTestCase.java | 27 +++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/core/src/org/labkey/core/wiki/MarkdownServiceImpl.java b/core/src/org/labkey/core/wiki/MarkdownServiceImpl.java index 064d35930ce..c8ec46de4b4 100644 --- a/core/src/org/labkey/core/wiki/MarkdownServiceImpl.java +++ b/core/src/org/labkey/core/wiki/MarkdownServiceImpl.java @@ -23,10 +23,15 @@ import org.commonmark.ext.image.attributes.ImageAttributesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlNodeRendererContext; import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.renderer.html.CoreHtmlNodeRenderer; +import org.commonmark.node.HtmlInline; +import org.commonmark.node.HtmlBlock; import org.labkey.api.markdown.MarkdownService; import java.util.List; +import java.util.Set; public class MarkdownServiceImpl implements MarkdownService { @@ -55,10 +60,65 @@ public MarkdownServiceImpl() .softbreak("
\n") // See Issue #34169 .sanitizeUrls(true) .escapeHtml(true) + .nodeRendererFactory(CommentNodeRenderer::new) .extensions(extensions) .build(); } + private static class CommentNodeRenderer extends CoreHtmlNodeRenderer + { + private final HtmlNodeRendererContext _context; + + public CommentNodeRenderer(HtmlNodeRendererContext context) + { + super(context); + _context = context; + } + + @Override + public Set> getNodeTypes() + { + return Set.of(HtmlInline.class, HtmlBlock.class); + } + + @Override + public void render(Node node) + { + if (node instanceof HtmlInline inline) + { + String literal = inline.getLiteral(); + if (isComment(literal)) + { + _context.getWriter().raw(literal); + } + else + { + _context.getWriter().text(literal); + } + } + else if (node instanceof HtmlBlock block) + { + String literal = block.getLiteral(); + if (isComment(literal)) + { + _context.getWriter().raw(literal); + } + else + { + _context.getWriter().tag("p"); + _context.getWriter().text(literal); + _context.getWriter().tag("/p"); + _context.getWriter().line(); + } + } + } + + private boolean isComment(String literal) + { + return literal != null && literal.trim().startsWith(""); + } + } + @Override public String toHtml(String mdText) { diff --git a/core/src/org/labkey/core/wiki/MarkdownTestCase.java b/core/src/org/labkey/core/wiki/MarkdownTestCase.java index f1c6402f787..a54fcea07c4 100644 --- a/core/src/org/labkey/core/wiki/MarkdownTestCase.java +++ b/core/src/org/labkey/core/wiki/MarkdownTestCase.java @@ -15,7 +15,7 @@ class MarkdownTestCase extends Assert @Test public void testMdHeadingToHtml() { - MarkdownService markdownService = MarkdownService.get(); + MarkdownService markdownService = new MarkdownServiceImpl(); String testMdText = "# This is a H1 header"; String expectedHtmlText = "

This is a H1 header

\n
"; String htmlText = markdownService.toHtml(testMdText); @@ -28,7 +28,7 @@ public void testMdHeadingToHtml() @Test public void testMdBoldToHtml() { - MarkdownService markdownService = MarkdownService.get(); + MarkdownService markdownService = new MarkdownServiceImpl(); String testMdText = "**This is bold text**"; String expectedHtmlText = "

This is bold text

\n
"; String htmlText = markdownService.toHtml(testMdText); @@ -41,11 +41,10 @@ public void testMdBoldToHtml() @Test public void testMdHtmlTags() { - MarkdownService markdownService = MarkdownService.get(); - + MarkdownService markdownService = new MarkdownServiceImpl(); String testMdText = "

header

"; - String expectedHtmlText = "

<h2>header</h2>

\n
"; String htmlText = markdownService.toHtml(testMdText); + String expectedHtmlText = "

<h2>header</h2>

\n
"; assertEquals("The MarkdownService failed to correctly escape html tags.", expectedHtmlText, htmlText); testMdText = ""; @@ -60,7 +59,7 @@ public void testMdHtmlTags() @Test public void testMdComplexToHtml() { - MarkdownService markdownService = MarkdownService.get(); + MarkdownService markdownService = new MarkdownServiceImpl(); // this sample of markdown and translation taken from part of: https://markdown-it.github.io/ String testMdText = """ --- @@ -341,4 +340,20 @@ public void testMdComplexToHtml() String htmlText = markdownService.toHtml(testMdText); assertEquals("The MarkdownService failed to correctly translate complex markdown text to html.", expectedHtmlText, htmlText); } + @Test + public void testHtmlComments() + { + MarkdownService markdownService = new MarkdownServiceImpl(); + + String testMdText = "Text before text after"; + String htmlText = markdownService.toHtml(testMdText); + + assertTrue("Comment was encoded: " + htmlText, htmlText.contains("")); + assertFalse("Comment should not be encoded: " + htmlText, htmlText.contains("<!--")); + + // Verification for "; + String scriptHtml = markdownService.toHtml(scriptMd); + assertTrue("Script tags should still be encoded: " + scriptHtml, scriptHtml.contains("<script>")); + } } From 214773a03b4dd47a467afe51ddd153a796d548ec Mon Sep 17 00:00:00 2001 From: Trey Chadick Date: Wed, 4 Feb 2026 14:27:21 -0800 Subject: [PATCH 05/11] Use all-caps label for metadata PK name (#7388) --- api/src/org/labkey/api/data/dialect/PkMetaDataReader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/data/dialect/PkMetaDataReader.java b/api/src/org/labkey/api/data/dialect/PkMetaDataReader.java index 89e3c831f7d..e8efaf2c5a7 100644 --- a/api/src/org/labkey/api/data/dialect/PkMetaDataReader.java +++ b/api/src/org/labkey/api/data/dialect/PkMetaDataReader.java @@ -44,6 +44,6 @@ public int getKeySeq() throws SQLException public String getKeyName() throws SQLException { - return _rsCols.getString("pk_name"); + return _rsCols.getString("PK_NAME"); } } From cda27b2a52efdf846da863e0ba023d6dfbd77199 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 5 Feb 2026 07:00:17 -0800 Subject: [PATCH 06/11] Give Troubleshooters the ability to see attachment counts and details (#7386) (#7392) --- .../labkey/core/admin/AdminController.java | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index da9247a2861..6d7113a4558 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -191,6 +191,7 @@ import org.labkey.api.security.AdminConsoleAction; import org.labkey.api.security.CSRF; import org.labkey.api.security.Directive; +import org.labkey.api.security.ElevatedUser; import org.labkey.api.security.Group; import org.labkey.api.security.GroupManager; import org.labkey.api.security.IgnoresTermsOfUse; @@ -3613,14 +3614,22 @@ protected String getQueryName() abstract protected UserSchema getUserSchema(); } - // This allows Troubleshooters to GET and POST to the action, supporting export to Excel and script, e.g. - @RequiresPermission(TroubleshooterPermission.class) - public class AttachmentsAction extends AbstractAdminQueryAction + private abstract static class AbstractAttachmentQueryAction extends AbstractAdminQueryAction { - @SuppressWarnings("unused") // Invoked via reflection - public AttachmentsAction() + public AbstractAttachmentQueryAction(String queryName) + { + super("core", queryName); + } + + @Override + public void setViewContext(ViewContext context) { - super("core", "DocumentsGroupedByParentTypeAdmin"); + // Give Troubleshooters (and ImpersonatingTroubleshooters) read permissions in all containers so they can + // see attachment counts by parent type plus details. I don't love poking an elevated user into the + // ViewContext, but this is the only way I could get DataRegion to see read permission on tables that are + // wrapped by a query (e.g., core.Documents used by DocumentsGroupedByParentType.sql). + context.setUser(ElevatedUser.getElevatedUser(context.getUser(), ReaderRole.class)); + super.setViewContext(context); } @Override @@ -3628,6 +3637,17 @@ protected UserSchema getUserSchema() { return new CoreQuerySchema(getUser(), getContainer(), false); } + } + + // This allows Troubleshooters to GET and POST to the action, supporting export to Excel and script, e.g. + @RequiresPermission(TroubleshooterPermission.class) + public class AttachmentsAction extends AbstractAttachmentQueryAction + { + @SuppressWarnings("unused") // Invoked via reflection + public AttachmentsAction() + { + super("DocumentsGroupedByParentTypeAdmin"); + } @Override public void addNavTrail(NavTree root) @@ -3638,18 +3658,12 @@ public void addNavTrail(NavTree root) @SuppressWarnings("unused") // Linked from core.DocumentsGroupedByParentTypeAdmin @RequiresPermission(TroubleshooterPermission.class) - public class AttachmentsForTypeAction extends AbstractAdminQueryAction + public class AttachmentsForTypeAction extends AbstractAttachmentQueryAction { @SuppressWarnings("unused") // Invoked via reflection public AttachmentsForTypeAction() { - super("core", "Documents"); - } - - @Override - protected UserSchema getUserSchema() - { - return new CoreQuerySchema(getUser(), getContainer(), false); + super("Documents"); } @Override From d319bea619906cc3daad777552a72953690e223e Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 6 Feb 2026 06:54:02 -0800 Subject: [PATCH 07/11] Private issue tracker search results (#7384) * don't throw * Suppress restricted issue exception on search --------- Co-authored-by: Lum --- issues/src/org/labkey/issue/model/IssueManager.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/src/org/labkey/issue/model/IssueManager.java b/issues/src/org/labkey/issue/model/IssueManager.java index 617024a7ac3..447fcc8901a 100644 --- a/issues/src/org/labkey/issue/model/IssueManager.java +++ b/issues/src/org/labkey/issue/model/IssueManager.java @@ -1038,7 +1038,7 @@ public HttpView getCustomSearchResult(User user, @NotNull String resourceIdentif return null; } - final IssueObject issue = getIssue(null, user, issueId); + final IssueObject issue = getIssue(null, user, issueId, false); if (null == issue) return null; Container c = issue.lookupContainer(); @@ -1319,7 +1319,7 @@ public static WebdavResource resolve(String id) return null; } - final IssueObject issue = getIssue(null, User.getSearchUser(), issueId); + final IssueObject issue = getIssue(null, User.getSearchUser(), issueId, false); if (null == issue) return null; From 4410101f3fc2eff9538fd53d372ae4025147b9ae Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 12 Feb 2026 09:26:04 -0800 Subject: [PATCH 08/11] Fix wiki delete npe and hide datasource test button (#7401) --- .../src/org/labkey/query/controllers/QueryController.java | 7 ++++--- wiki/src/org/labkey/wiki/WikiController.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 35ba9a5d411..0ed4aa66be9 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -714,7 +714,8 @@ public DataSourceAdminAction(ViewContext viewContext) public ModelAndView getView(Object o, BindException errors) { // Site Admin or Troubleshooter? Troubleshooters can see all the information but can't test data sources. - boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); + // Dev mode only, since "Test" is meant for LabKey's own development and testing purposes. + boolean showTestButton = getContainer().hasPermission(getUser(), AdminOperationsPermission.class) && AppProps.getInstance().isDevMode(); List allDefs = QueryManager.get().getExternalSchemaDefs(null); MultiValuedMap byDataSourceName = new ArrayListValuedHashMap<>(); @@ -729,7 +730,7 @@ public ModelAndView getView(Object o, BindException errors) BR(), TABLE(cl("labkey-data-region"), TR(cl("labkey-show-borders"), - hasAdminOpsPerms ? TD(cl("labkey-column-header"), "Test") : null, + showTestButton ? TD(cl("labkey-column-header"), "Test") : null, TD(cl("labkey-column-header"), "Data Source"), TD(cl("labkey-column-header"), "Current Status"), TD(cl("labkey-column-header"), "URL"), @@ -759,7 +760,7 @@ public ModelAndView getView(Object o, BindException errors) return Stream.of( TR( cl(rowStyle), - hasAdminOpsPerms ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, + showTestButton ? TD(connected ? new ButtonBuilder("Test").href(new ActionURL(TestDataSourceConfirmAction.class, getContainer()).addParameter("dataSource", scope.getDataSourceName())) : "") : null, TD(HtmlString.NBSP, scope.getDisplayName()), TD(status), TD(scope.getDatabaseUrl()), diff --git a/wiki/src/org/labkey/wiki/WikiController.java b/wiki/src/org/labkey/wiki/WikiController.java index a26266905d8..a9c9c35cc7a 100644 --- a/wiki/src/org/labkey/wiki/WikiController.java +++ b/wiki/src/org/labkey/wiki/WikiController.java @@ -427,7 +427,7 @@ public ActionURL getCancelUrl() @Override public ActionURL getFailURL(WikiNameForm wikiNameForm, BindException errors) { - return new ManageAction(getViewContext(), _wiki).getUrl(); + return _wiki != null ? new ManageAction(getViewContext(), _wiki).getUrl() : null; } } From a7a48e2657af31985e1aa91eb3b89e2a71162377 Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Mon, 16 Feb 2026 08:03:06 -0800 Subject: [PATCH 09/11] Better handling for an empty UnreferencedSampleFiles table and make test reset flags (#7414) --- .../ExpUnreferencedSampleFilesTableImpl.java | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java index fb4cbe5ab2f..cdb35f20821 100644 --- a/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java @@ -48,38 +48,42 @@ public FileUnionTable(@NotNull ExpSchema schema) FileContentService svc = FileContentService.get(); _query = new SQLFragment(); - if (svc == null) - return; - - SQLFragment listQuery = svc.listSampleFilesQuery(schema.getUser()); - if (StringUtils.isEmpty(listQuery)) - return; - - TableInfo expDataTable = ExperimentService.get().getTinfoData(); - TableInfo materialTable = ExperimentService.get().getTinfoMaterial(); + SQLFragment listQuery = new SQLFragment(); + if (svc != null) + listQuery = svc.listSampleFilesQuery(schema.getUser()); _query.appendComment("", getSchema().getSqlDialect()); - SQLFragment sampleFileSql = new SQLFragment("SELECT m.Container, if.FilePathShort \n") - .append("FROM (") - .append(svc.listSampleFilesQuery(schema.getUser())) - .append(") AS if \n") - .append("JOIN ") - .append(materialTable, "m") - .append(" ON if.SourceKey = m.RowId"); - - SQLFragment unreferencedFileSql = new SQLFragment("SELECT ed.rowId, ed.name as filename, ed.container, ed.created, ed.createdBy, ed.DataFileUrl FROM ") - .append(expDataTable, "ed") - .append(" LEFT JOIN (") - .append(sampleFileSql) - .append(" ) sf\n") - .append(" ON ed.name = sf.FilePathShort AND ed.container = sf.container\n") - .append(" WHERE ed.datafileurl LIKE ") - .appendValue("%@files/sampletype/%") - .append(" AND sf.FilePathShort IS NULL"); - - _query.append(unreferencedFileSql); + TableInfo expDataTable = ExperimentService.get().getTinfoData(); + if (!StringUtils.isEmpty(listQuery)) + { + TableInfo materialTable = ExperimentService.get().getTinfoMaterial(); + + SQLFragment sampleFileSql = new SQLFragment("SELECT m.Container, if.FilePathShort \n") + .append("FROM (") + .append(listQuery) + .append(") AS if \n") + .append("JOIN ") + .append(materialTable, "m") + .append(" ON if.SourceKey = m.RowId"); + + SQLFragment unreferencedFileSql = new SQLFragment("SELECT ed.rowId, ed.name as filename, ed.container, ed.created, ed.createdBy, ed.DataFileUrl FROM ") + .append(expDataTable, "ed") + .append(" LEFT JOIN (") + .append(sampleFileSql) + .append(" ) sf\n") + .append(" ON ed.name = sf.FilePathShort AND ed.container = sf.container\n") + .append(" WHERE ed.datafileurl LIKE ") + .appendValue("%@files/sampletype/%") + .append(" AND sf.FilePathShort IS NULL"); + + _query.append(unreferencedFileSql); + } + else + { + _query.append("SELECT RowId, FileName, Container, Created, CreatedBy, DataFileUrl FROM ").append(expDataTable).append(" WHERE (1=0)"); + } _query.appendComment("", getSchema().getSqlDialect()); var rowIdCol = new BaseColumnInfo("RowId", this, JdbcType.INTEGER); From df97b93e5b318fad421350c95c358f323632f869 Mon Sep 17 00:00:00 2001 From: Cory Nathe Date: Mon, 16 Feb 2026 10:43:14 -0600 Subject: [PATCH 10/11] ExpUnreferencedSampleFilesTableImpl.FileUnionTable fix for FileContentService NULL case select statement (#7420) - select Name column and alias it as FileName --- .../experiment/api/ExpUnreferencedSampleFilesTableImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java index cdb35f20821..ffd0e429609 100644 --- a/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpUnreferencedSampleFilesTableImpl.java @@ -82,7 +82,7 @@ public FileUnionTable(@NotNull ExpSchema schema) } else { - _query.append("SELECT RowId, FileName, Container, Created, CreatedBy, DataFileUrl FROM ").append(expDataTable).append(" WHERE (1=0)"); + _query.append("SELECT RowId, Name AS FileName, Container, Created, CreatedBy, DataFileUrl FROM ").append(expDataTable).append(" WHERE (1=0)"); } _query.appendComment("", getSchema().getSqlDialect()); From 8d961f012d5ecc241ccf93e4390d26d1a9284b83 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Mon, 16 Feb 2026 15:36:39 -0800 Subject: [PATCH 11/11] Add missing text descriptions for UI elements (#7417) --- api/src/org/labkey/api/view/menu/NavTreeMenu.java | 3 ++- .../core/view/template/bootstrap/ViewServiceImpl.java | 6 ++++-- core/src/org/labkey/core/view/template/bootstrap/header.jsp | 2 +- search/src/org/labkey/search/view/search.jsp | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/api/src/org/labkey/api/view/menu/NavTreeMenu.java b/api/src/org/labkey/api/view/menu/NavTreeMenu.java index a5e57e8a607..7345d4092a8 100644 --- a/api/src/org/labkey/api/view/menu/NavTreeMenu.java +++ b/api/src/org/labkey/api/view/menu/NavTreeMenu.java @@ -201,10 +201,11 @@ private void renderLinks(NavTree nav, int level, String pathToHere, String rootI ActionURL expandCollapseUrl = Objects.requireNonNull(PageFlowUtil.urlProvider(ProjectUrls.class)).getExpandCollapseURL(getViewContext().getContainer(), pathToHere, rootId); String image = collapsed ? "plus.gif" : "minus.gif"; + String altPrefix = collapsed ? "Expand" : "Collapse"; id = config.makeId("navtree"); oldWriter.printf("", id, filter(expandCollapseUrl)); config.addHandler(id, "click", "return LABKEY.Utils.toggleLink(this,true);"); - oldWriter.printf("", context.getContextPath(), image); + oldWriter.printf("\"%s", filter(context.getContextPath()), filter(image), filter(altPrefix), filter(nav.getText())); } else if (indentForExpansionGifs) oldWriter.printf("
"); diff --git a/core/src/org/labkey/core/view/template/bootstrap/ViewServiceImpl.java b/core/src/org/labkey/core/view/template/bootstrap/ViewServiceImpl.java index 08c7d618fe2..23885cce85e 100644 --- a/core/src/org/labkey/core/view/template/bootstrap/ViewServiceImpl.java +++ b/core/src/org/labkey/core/view/template/bootstrap/ViewServiceImpl.java @@ -666,7 +666,7 @@ public void renderCollapsiblePortalTitle(PrintWriter out) public void renderCustomDropDown(String title, NavTree current, PrintWriter out) { - renderMenuWithFontImage(null, current, out, null, false); + renderMenuWithFontImage(title, current, out, null, false); } } @@ -750,7 +750,9 @@ private void renderMenuWithFontImage(String title, NavTree menu, PrintWriter out if (rightAlign) out.print(" pull-right"); out.print("\">"); - out.print(""); out.print(""); diff --git a/core/src/org/labkey/core/view/template/bootstrap/header.jsp b/core/src/org/labkey/core/view/template/bootstrap/header.jsp index e147df531db..a5d95b301f5 100644 --- a/core/src/org/labkey/core/view/template/bootstrap/header.jsp +++ b/core/src/org/labkey/core/view/template/bootstrap/header.jsp @@ -143,7 +143,7 @@ { %>