From 390eed2ee66a749275e92eb3c1556231d7391a13 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Tue, 14 Apr 2026 11:04:52 -0600 Subject: [PATCH 1/9] Close InputStream when validating assay transform scripts during save (#7578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale We read transform scripts during assay design save. We open but don't close the file. This is impolite, and Windows automated tests have been noticing. https://teamcity.labkey.org/buildConfiguration/LabKey_263Release_Premium_CommunitySqlserver_DailyCSqlserver/3929893?buildTab=tests&status=failed&expandedTest=build%3A%28id%3A3929893%29%2Cid%3A2000000048 #### Related Pull Requests - https://github.com/LabKey/testAutomation/pull/2946 #### Changes - try-with-resources is the polite way to handle InputStreams --- api/src/org/labkey/api/assay/AbstractAssayProvider.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/assay/AbstractAssayProvider.java b/api/src/org/labkey/api/assay/AbstractAssayProvider.java index 0f4b1988530..c620c02341b 100644 --- a/api/src/org/labkey/api/assay/AbstractAssayProvider.java +++ b/api/src/org/labkey/api/assay/AbstractAssayProvider.java @@ -124,6 +124,7 @@ import javax.script.ScriptEngine; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.URL; import java.sql.ResultSet; @@ -1274,9 +1275,9 @@ public Pair> setValidationAndAnalysisS if (!(engine instanceof ExternalScriptEngine && ((ExternalScriptEngine) engine).isBinary(scriptFile))) { String scriptText; - try + try (InputStream is = scriptFile.openInputStream()) { - scriptText = IOUtils.toString(scriptFile.openInputStream(), StringUtilsLabKey.DEFAULT_CHARSET); + scriptText = IOUtils.toString(is, StringUtilsLabKey.DEFAULT_CHARSET); } catch (IOException e) { From 5999bb42af08454b26778dd7272004ae245006b3 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 14 Apr 2026 11:21:44 -0700 Subject: [PATCH 2/9] Register filters & servlets later + CSP improvements (#7550) --- api/src/org/labkey/api/mcp/McpService.java | 9 +- .../org/labkey/api/module/ModuleLoader.java | 37 +++-- .../filters/ContentSecurityPolicyFilter.java | 137 +++++------------- core/src/org/labkey/core/CoreModule.java | 3 +- query/src/org/labkey/query/QueryModule.java | 21 +-- 5 files changed, 78 insertions(+), 129 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 63bfcaabfa6..6ad04872537 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -29,11 +29,11 @@ /// ### MCP Development Guide /// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external /// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions -/// hosted by LabKey (see `AbstractAgentAction``). +/// hosted by LabKey (see `AbstractAgentAction`). /// /// ### Adding a new MCP class /// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module -/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())` +/// 2. Register that class in your module's `startup()` method: `McpService.get().register(new MyMcp())` /// 3. Add tools and resources /// /// ### Adding a new MCP tool @@ -44,13 +44,13 @@ /// permission annotation is required, otherwise your tool will not be registered.** /// 4. Add `ToolContext` as the first parameter to the method /// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the -/// default. Again here, the parameter descriptions are very important. Provide examples. +/// default. Again here, the parameter descriptions are very important. Provide examples of parameter values. /// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User` /// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User` /// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate /// 9. Filter all results to the current container, of course /// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate -/// failure responses and the LLM client will attempt to correct the problem. +/// failure responses and the LLM client will attempt to correct any problems (hopefully). /// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring /// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See /// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation. @@ -126,6 +126,7 @@ static void setInstance(McpService service) boolean isReady(); + // Register MCPs in Module.startup() default void register(McpImpl mcp) { try diff --git a/api/src/org/labkey/api/module/ModuleLoader.java b/api/src/org/labkey/api/module/ModuleLoader.java index a43af596db6..6248d1b0e75 100644 --- a/api/src/org/labkey/api/module/ModuleLoader.java +++ b/api/src/org/labkey/api/module/ModuleLoader.java @@ -580,19 +580,6 @@ private void doInit(Execution execution) throws ServletException throw new IllegalStateException("Core module was not first or could not find the Core module. Ensure that Tomcat user can create directories under the /modules directory."); setProjectRoot(coreModule); - for (Module module : modules) - { - module.registerFilters(_servletContext); - } - for (Module module : modules) - { - module.registerServlets(_servletContext); - } - for (Module module : modules) - { - module.registerFinalServlets(_servletContext); - } - // Do this after we've checked to see if we can find the core module. See issue 22797. verifyProductionModeMatchesBuild(); @@ -790,12 +777,34 @@ public void addStaticWarnings(@NotNull Warnings warnings, boolean showAllWarning if (!modulesRequiringUpgrade.isEmpty() || !additionalSchemasRequiringUpgrade.isEmpty()) setUpgradeState(UpgradeState.UpgradeRequired); - // Don't accept any requests if we're bootstrapping empty schemas or migrating from SQL Server + // Don't accept any requests if we're bootstrapping empty schemas or doing a database migration if (!shouldInsertData()) execution = Execution.Synchronous; startNonCoreUpgradeAndStartup(execution, lockFile); + // Register filters and servlets at the last minute, just before Tomcat starts. At this point, the list of + // modules is final. We've had one case where the CSP filter was getting initialized before the core module + // was initialized, GitHub Issue 1008. We have no idea how that happened, but registering late won't hurt. + + _log.info("Registering filters"); + + for (Module module : _modules) + { + module.registerFilters(_servletContext); + } + + _log.info("Registering servlets"); + + for (Module module : _modules) + { + module.registerServlets(_servletContext); + } + for (Module module : _modules) + { + module.registerFinalServlets(_servletContext); + } + _log.info("LabKey Server startup is complete; {}", execution.getLogMessage()); } diff --git a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java index 90a9b1c5437..dc077525b6b 100644 --- a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java +++ b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java @@ -12,7 +12,6 @@ import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.EnumUtils; 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.jetbrains.annotations.Nullable; @@ -30,7 +29,6 @@ import org.labkey.api.util.StringExpressionFactory; import org.labkey.api.util.StringExpressionFactory.AbstractStringExpression.NullValueBehavior; import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; import java.io.IOException; import java.security.SecureRandom; @@ -40,13 +38,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; - /** * Content Security Policies (CSPs) are loaded from the csp.enforce and csp.report properties in application.properties. */ @@ -57,6 +53,7 @@ public class ContentSecurityPolicyFilter implements Filter private static final String NONCE_SUBST = "REQUEST.SCRIPT.NONCE"; private static final String UPGRADE_INSECURE_REQUESTS_SUBSTITUTION = "UPGRADE.INSECURE.REQUESTS"; private static final String HEADER_NONCE = "org.labkey.filters.ContentSecurityPolicyFilter#NONCE"; // needs to match PageConfig.HEADER_NONCE + private static final String REPORTING_ENDPOINTS_HEADER = "Reporting-Endpoints"; private static final Map CSP_FILTERS = new CopyOnWriteHashMap<>(); @@ -76,11 +73,14 @@ public class ContentSecurityPolicyFilter implements Filter private @NotNull String _cspVersion = "Unknown"; // These two are effectively @NotNull since they are set to non-null values in init() and never changed private String _stashedTemplate = null; - private String _reportToEndpointName = null; - // Per-filter-instance settings are initialized on first request and reset when base server URL or allowed sources - // change. Don't reference this directly; always use ensureSettings(). - private volatile @Nullable CspFilterSettings _settings = null; + // We can't set this statically because the class is referenced before URLProviders are available + @SuppressWarnings("DataFlowIssue") + private final String _reportingEndpointsHeaderValue = "csp-report=\"" + PageFlowUtil.urlProvider(AdminUrls.class).getCspReportToURL().getLocalURIString() + "\""; + + // Initialized on first request and reset when allowed sources change. Don't reference this directly; always use + // ensurePolicyExpression(). + private volatile StringExpression _policyExpression; public enum ContentSecurityPolicyType { @@ -118,7 +118,7 @@ public String getHeaderName() @Override public void init(FilterConfig filterConfig) throws ServletException { - LogHelper.getLogger(ContentSecurityPolicyFilter.class, "CSP filter initialization").info("Initializing {}", filterConfig.getFilterName()); + LOG.info("Initializing {}", filterConfig.getFilterName()); Enumeration paramNames = filterConfig.getInitParameterNames(); while (paramNames.hasMoreElements()) { @@ -146,9 +146,6 @@ else if ("disposition".equalsIgnoreCase(paramName)) if (CSP_FILTERS.put(getType(), this) != null) throw new ServletException("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + getType()); - - // configure a different endpoint for each type. TODO: We only need one CSP violation reporting endpoint now, so one header would do - _reportToEndpointName = "csp-" + getType().name().toLowerCase(); } /** Filter out block comments and replace special characters in the provided policy */ @@ -197,18 +194,21 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha { if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp) { - CspFilterSettings settings = ensureSettings(); + StringExpression expression = ensurePolicyExpression(); if (getType() != ContentSecurityPolicyType.Enforce || !OptionalFeatureService.get().isFeatureEnabled(FEATURE_FLAG_DISABLE_ENFORCE_CSP)) { Map map = Map.of(NONCE_SUBST, getScriptNonceHeader(req)); - var csp = settings.getPolicyExpression().eval(map); - resp.setHeader(getType().getHeaderName(), csp); + String csp = expression.eval(map); + + if ("https".equals(req.getScheme())) + { + if (resp.getHeader(REPORTING_ENDPOINTS_HEADER) == null) + resp.addHeader(REPORTING_ENDPOINTS_HEADER, _reportingEndpointsHeaderValue); + csp = csp + " report-to csp-report ;"; + } - // null if https: is not configured on this server - String reportingEndpointsHeaderValue = settings.getReportingEndpointsHeaderValue(); - if (reportingEndpointsHeaderValue != null) - resp.addHeader("Reporting-Endpoints", reportingEndpointsHeaderValue); + resp.setHeader(getType().getHeaderName(), csp); } } chain.doFilter(request, response); @@ -229,91 +229,26 @@ public String getStashedTemplate() return _stashedTemplate; } - public String getReportToEndpointName() + private void clearPolicyExpression() { - return _reportToEndpointName; + _policyExpression = null; } - private void clearSettings() + private @NotNull StringExpression ensurePolicyExpression() { - _settings = null; - } + StringExpression expression = _policyExpression; - private @NotNull CspFilterSettings ensureSettings() - { - String baseServerUrl = AppProps.getInstance().getBaseServerUrl(); - CspFilterSettings settings = _settings; // Stash a local copy to ensure consistency in the checks below - - // Reset settings if null or if base server URL has changed - if (null == settings || !Objects.equals(baseServerUrl, settings.getPreviousBaseServerUrl())) + if (expression == null) { - settings = _settings = new CspFilterSettings(this, baseServerUrl); - } - - return settings; - } - - // Hold all the mutable per-filter settings in a single object so they can be set atomically - private static class CspFilterSettings - { - private final String _policyTemplate; - private final String _reportingEndpointsHeaderValue; - private final String _previousBaseServerUrl; - private final StringExpression _policyExpression; - - private CspFilterSettings(ContentSecurityPolicyFilter filter, String 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(); - // Use an absolute URL so we always post to https:, even if the violating request uses http: - _reportingEndpointsHeaderValue = filter.getReportToEndpointName() + "=\"" + violationUrl.getURIString() + "\""; - - // Add "report-to" directive to the policy - _policyTemplate = filter.getStashedTemplate() + " report-to " + filter.getReportToEndpointName() + " ;"; - } - else - { - _policyTemplate = filter.getStashedTemplate(); - _reportingEndpointsHeaderValue = null; - } - - _previousBaseServerUrl = baseServerUrl; - - final String substitutedPolicy; - synchronized (SUBSTITUTION_LOCK) { - substitutedPolicy = StringExpressionFactory.create(_policyTemplate, false, NullValueBehavior.KeepSubstitution) + var substitutedPolicy = StringExpressionFactory.create(getStashedTemplate(), false, NullValueBehavior.KeepSubstitution) .eval(SUBSTITUTION_MAP); + expression = _policyExpression = StringExpressionFactory.create(substitutedPolicy, false, NullValueBehavior.ReplaceNullAndMissingWithBlank); } - - _policyExpression = StringExpressionFactory.create(substitutedPolicy, false, NullValueBehavior.ReplaceNullAndMissingWithBlank); } - public String getPolicyTemplate() - { - return _policyTemplate; - } - - public String getReportingEndpointsHeaderValue() - { - return _reportingEndpointsHeaderValue; - } - - public String getPreviousBaseServerUrl() - { - return _previousBaseServerUrl; - } - - public StringExpression getPolicyExpression() - { - return _policyExpression; - } + return expression; } public static String getScriptNonceHeader(HttpServletRequest request) @@ -362,7 +297,7 @@ public static void unregisterAllowedSources(String key, Directive directive) /** * Regenerate the substitution map on every register/unregister. The policy expression will be regenerated on the - * next request (see {@link #ensureSettings()}). + * next request (see {@link #ensurePolicyExpression()}). */ public static void regenerateSubstitutionMap() { @@ -389,7 +324,7 @@ public static void regenerateSubstitutionMap() // Tell each registered ContentSecurityPolicyFilter to clear its settings so the next request recreates them // using the new substitution map - CSP_FILTERS.values().forEach(ContentSecurityPolicyFilter::clearSettings); + CSP_FILTERS.values().forEach(ContentSecurityPolicyFilter::clearPolicyExpression); } } @@ -436,7 +371,7 @@ public static List getMissingSubstitutions(ContentSecurityPolicyType typ } else { - String template = filter.ensureSettings().getPolicyTemplate(); + String template = filter.getStashedTemplate(); ret = Arrays.stream(Directive.values()) .map(dir -> "${" + dir.getSubstitutionKey() + "}") .filter(key -> !template.contains(key)) @@ -451,13 +386,15 @@ public static void registerMetricsProvider() UsageMetricsService.get().registerUsageMetrics("API", () -> Map.of("cspFilters", CSP_FILTERS.values().stream() .collect(Collectors.toMap(ContentSecurityPolicyFilter::getType, filter -> { - CspFilterSettings settings = filter.ensureSettings(); + StringExpression expression = filter.ensurePolicyExpression(); return Map.of( "version", filter.getCspVersion(), - "csp", settings.getPolicyTemplate(), - "cspSubstituted", settings.getPolicyExpression().getSource() + "csp", filter.getStashedTemplate(), + "cspSubstituted", expression.getSource() ); - })))); + })) + ) + ); } public static class TestCase extends Assert @@ -630,7 +567,7 @@ public void testSubstitutionMap() private void verifySubstitutionInPolicyExpressions(String value, int expectedCount) { List failures = CSP_FILTERS.values().stream() - .map(filter -> filter.ensureSettings().getPolicyExpression().eval(Map.of())) + .map(filter -> filter.ensurePolicyExpression().eval(Map.of())) .filter(policy -> StringUtils.countMatches(policy, value) != expectedCount) .toList(); diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 2312ec48f5f..fd2fd568258 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -559,7 +559,6 @@ public QuerySchema createSchema(DefaultSchema schema, Module module) ScriptEngineManagerImpl.registerEncryptionMigrationHandler(); - McpService.get().register(new CoreMcp()); PostgreSqlService.setInstance(PostgreSqlDialectFactory::getLatestSupportedDialect); deleteTempFiles(); @@ -1283,6 +1282,8 @@ public void moduleStartupComplete(ServletContext servletContext) UserManager.addUserListener(new EmailPreferenceUserListener()); Encryption.checkMigration(); + + McpService.get().register(new CoreMcp()); } // Issue 7527: Auto-detect missing SQL views and attempt to recreate diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 0630786a1a2..15301c414c9 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -247,8 +247,6 @@ public QuerySchema createSchema(DefaultSchema schema, Module module) "Allow for lookup fields in product folders to query across all folders within the top-level folder.", false); OptionalFeatureService.get().addExperimentalFeatureFlag(QueryServiceImpl.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, "Product folders display folder-specific data", "Only list folder-specific data within product folders.", false); - - McpService.get().register(new QueryMcp()); } @@ -350,11 +348,14 @@ public void doStartup(ModuleContext moduleContext) trustedAnalystRole.addPermission(EditQueriesPermission.class); OptionalFeatureService.get().addFeatureFlag(new OptionalFeatureFlag(R_REPORT_CUSTOM_SHARING, - "Restore custom R report sharing", - "Allows R reports to be shared on a per user basis. This option will be removed in LabKey Server 26.7.", - false, - false, - OptionalFeatureService.FeatureType.Deprecated)); + "Restore custom R report sharing", + "Allows R reports to be shared on a per user basis. This option will be removed in LabKey Server 26.7.", + false, + false, + OptionalFeatureService.FeatureType.Deprecated) + ); + + McpService.get().register(new QueryMcp()); } @Override @@ -390,13 +391,13 @@ public Set getSchemaNames() return Set.of( ModuleReportCache.TestCase.class, OlapController.TestCase.class, - QueryController.TestCase.class, QueryController.SaveRowsTestCase.class, + QueryController.TestCase.class, QueryServiceImpl.TestCase.class, RolapReader.RolapTest.class, RolapTestCase.class, - ServerManager.TestCase.class, - SelectRowsStreamHack.TestCase.class + SelectRowsStreamHack.TestCase.class, + ServerManager.TestCase.class ); } From 689a8350dbc809f3725695d234a204e482aacbb6 Mon Sep 17 00:00:00 2001 From: Susan Hert Date: Tue, 14 Apr 2026 14:24:19 -0700 Subject: [PATCH 3/9] Issue 1051: Fix up audit comments for cross-type imports and updates that update storage data (#7579) --- .github/pull_request_template.md | 4 ++-- experiment/src/org/labkey/experiment/ExpDataIterators.java | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 961f9495a36..8a67c0fb3b0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,6 +10,6 @@ \ No newline at end of file +--> diff --git a/experiment/src/org/labkey/experiment/ExpDataIterators.java b/experiment/src/org/labkey/experiment/ExpDataIterators.java index b14dacc468e..96cd1f66a90 100644 --- a/experiment/src/org/labkey/experiment/ExpDataIterators.java +++ b/experiment/src/org/labkey/experiment/ExpDataIterators.java @@ -3165,6 +3165,8 @@ private TypeData createSampleHeaderRow(ExpSampleTypeImpl sampleType, Container c validFields.add("Storage Unit Label"); // For consistency with other storage fields that are imported without spaces in the names validFields.add("EnteredStorage"); + validFields.add("StorageComment"); // GH Issue 1051 + validFields.add("Storage Comment"); List fieldIndexes = new IntArrayList(); Map dependencyIndexes = new IntHashMap<>(); List header = new ArrayList<>(); From da596f6ac1867280b5d1f1e1f6766595985b4554 Mon Sep 17 00:00:00 2001 From: Josh Eckels Date: Thu, 16 Apr 2026 07:11:50 -0600 Subject: [PATCH 4/9] Add aria-label for more items, improve keyboard accessibility of file browser (#7567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Rationale More elements in the header need aria-label values too. We also need to more in our file UI and when we show attachments. See details at https://www.labkey.org/MacCoss/support%20tickets/issues-download.view?issueId=54460&entityId=143f5498-165e-103f-837d-d61362bf9037&name=New%20Dubbot%20findings%20on%20https___skyline.ms.pdf #### Changes - Add the missing attributes in the header - Improve consistency for attachment rendering - Add labels to buttons in WebDav UI - Improve scrollability #### Tasks 📍 - [x] Manual Testing @labkey-matthewb - [x] Fix [FileContentTest](https://teamcity.labkey.org/buildConfiguration/LabKey_263Release_Community_ModuleSuites_FileContentPostgres/3934680) - ~Needs Automation~ - ~Verify Fix~ --- .../announcements/announcementThread.jsp | 4 +- .../announcementWebPartSimple.jsp | 2 +- .../announcementWebPartWithExpandos.jsp | 2 +- .../src/org/labkey/announcements/update.jsp | 1 - .../labkey/api/attachments/Attachment.java | 29 ++++++++++ api/src/org/labkey/api/util/DOM.java | 51 +++++++++++++++++- api/webapp/clientapi/dom/DataRegion.js | 4 +- .../org/labkey/core/login/resetPassword.jsp | 2 +- .../core/view/template/bootstrap/header.jsp | 8 +-- .../src/org/labkey/core/webdav/davListing.jsp | 16 ++++-- filecontent/webapp/File/panel/Actions.js | 18 ++++++- filecontent/webapp/File/panel/Browser.js | 54 +++++++++++++++++++ .../study/designer/view/studySummary.jsp | 10 +--- .../org/labkey/study/view/studySummary.jsp | 10 +--- wiki/src/org/labkey/wiki/view/wiki.jsp | 2 +- 15 files changed, 177 insertions(+), 36 deletions(-) diff --git a/announcements/src/org/labkey/announcements/announcementThread.jsp b/announcements/src/org/labkey/announcements/announcementThread.jsp index b636af6df12..1b8711bc415 100644 --- a/announcements/src/org/labkey/announcements/announcementThread.jsp +++ b/announcements/src/org/labkey/announcements/announcementThread.jsp @@ -127,7 +127,7 @@ if (!announcementModel.getAttachments().isEmpty()) { ActionURL downloadURL = AnnouncementsController.getDownloadURL(announcementModel, d.getName()); %> -  <%=h(d.getName())%> <% + <%=d.renderDownloadLink(downloadURL)%> <% } %> <% @@ -210,7 +210,7 @@ if (!announcementModel.getResponses().isEmpty()) { ActionURL downloadURL = AnnouncementsController.getDownloadURL(r, rd.getName()); %> -  <%=h(rd.getName())%> <% + <%=rd.renderDownloadLink(downloadURL)%> <% } %> diff --git a/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp b/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp index 713869f8ee8..dac75ebc99c 100644 --- a/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp +++ b/announcements/src/org/labkey/announcements/announcementWebPartSimple.jsp @@ -195,7 +195,7 @@ for (AnnouncementModel a : bean.announcementModels) for (Attachment d : a.getAttachments()) { ActionURL downloadURL = AnnouncementsController.getDownloadURL(a, d.getName()); - %> <%=h(d.getName())%> <% + %><%=d.renderDownloadLink(downloadURL)%> <% } %><% } diff --git a/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp b/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp index 4322dc4b7ed..8f061e75af7 100644 --- a/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp +++ b/announcements/src/org/labkey/announcements/announcementWebPartWithExpandos.jsp @@ -217,7 +217,7 @@ for (AnnouncementModel a : bean.announcementModels) for (Attachment d : a.getAttachments()) { ActionURL downloadURL = AnnouncementsController.getDownloadURL(a, d.getName()); - %> <%=h(d.getName())%> <% + %><%=d.renderDownloadLink(downloadURL)%> <% } %><% } diff --git a/announcements/src/org/labkey/announcements/update.jsp b/announcements/src/org/labkey/announcements/update.jsp index 1797588ed54..c5e54ef947b 100644 --- a/announcements/src/org/labkey/announcements/update.jsp +++ b/announcements/src/org/labkey/announcements/update.jsp @@ -140,7 +140,6 @@ if (settings.hasExpires()) <% int x = -1; - String id; for (Attachment att : ann.getAttachments()) { x++; diff --git a/api/src/org/labkey/api/attachments/Attachment.java b/api/src/org/labkey/api/attachments/Attachment.java index 904972dae6e..b946c8012d4 100644 --- a/api/src/org/labkey/api/attachments/Attachment.java +++ b/api/src/org/labkey/api/attachments/Attachment.java @@ -21,9 +21,13 @@ import org.labkey.api.security.User; import org.labkey.api.security.UserManager; import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.util.DOM; +import org.labkey.api.util.HtmlString; import org.labkey.api.util.MemTracker; import org.labkey.api.util.MimeMap; +import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Path; +import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewServlet; import org.labkey.api.webdav.WebdavResolver; @@ -350,4 +354,29 @@ public void setDocumentSize(int documentSize) { _documentSize = documentSize; } + + /** + * Returns an HtmlString rendering a download link: an anchor containing a file type icon and the filename. + * The icon is marked aria-hidden since it is decorative; the link text serves as the accessible name. + */ + public HtmlString renderDownloadLink(ActionURL downloadURL) + { + return renderDownloadLink(downloadURL, getName()); + } + + /** + * Returns an HtmlString rendering a download link: an anchor containing a file type icon and custom link text. + * Use this overload when the visible link label differs from the filename (e.g. "Study Protocol Document"). + * The icon is marked aria-hidden since it is decorative; linkText serves as the accessible name. + */ + public HtmlString renderDownloadLink(ActionURL downloadURL, String linkText) + { + return DOM.createHtmlFragment( + DOM.A(DOM.at(DOM.Attribute.href, downloadURL.toString()), + DOM.IMG(DOM.at(DOM.Attribute.alt, "").at(DOM.Attribute.src, PageFlowUtil.staticResourceUrl(getFileIcon()))), + HtmlString.NBSP, + linkText + ) + ); + } } diff --git a/api/src/org/labkey/api/util/DOM.java b/api/src/org/labkey/api/util/DOM.java index 1feb02da7b1..140e91deba9 100644 --- a/api/src/org/labkey/api/util/DOM.java +++ b/api/src/org/labkey/api/util/DOM.java @@ -362,6 +362,54 @@ public enum Attribute action, align, alt, + aria_activedescendant, + aria_atomic, + aria_autocomplete, + aria_busy, + aria_checked, + aria_colcount, + aria_colindex, + aria_colspan, + aria_controls, + aria_current, + aria_describedby, + aria_details, + aria_disabled, + aria_dropeffect, + aria_errormessage, + aria_expanded, + aria_flowto, + aria_grabbed, + aria_haspopup, + aria_hidden, + aria_invalid, + aria_keyshortcuts, + aria_label, + aria_labelledby, + aria_level, + aria_live, + aria_modal, + aria_multiline, + aria_multiselectable, + aria_orientation, + aria_owns, + aria_placeholder, + aria_posinset, + aria_pressed, + aria_readonly, + aria_relevant, + aria_required, + aria_roledescription, + aria_rowcount, + aria_rowindex, + aria_rowspan, + aria_selected, + aria_setsize, + aria_sort, + aria_valuemax, + aria_valuemin, + aria_valuenow, + aria_valuetext, async, autocomplete, autofocus, @@ -570,7 +618,6 @@ public _Attributes data(boolean condition, String datakey, Object value) } return this; } - public _Attributes cl(String...names) { if (null != names) @@ -891,7 +938,7 @@ private static Appendable appendAttribute(Appendable html, Attribute key, Object if (null==value) return html; html.append(" "); - html.append(key.name()); + html.append(key.name().replace('_', '-')); html.append("=\""); // NOTE it is somewhat unusual to pass in a Renderable, but it is possible that we // want to render HTML into an attribute. We still need to re-encode the value before trying to wrap with "". diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 07ddf75f5cc..a13e1f2c335 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -1713,8 +1713,8 @@ if (!LABKEY.DataRegions) { ct.append([ '
', - '', - '', + '', + '', '
' ].join('')); diff --git a/core/src/org/labkey/core/login/resetPassword.jsp b/core/src/org/labkey/core/login/resetPassword.jsp index ea9e89d1c86..9a62a04ad5a 100644 --- a/core/src/org/labkey/core/login/resetPassword.jsp +++ b/core/src/org/labkey/core/login/resetPassword.jsp @@ -46,7 +46,7 @@ <% } %>

To reset your password, type in your email address and click the Reset button.

- +
<%= button("Reset").submit(true).name("reset")%> <%= button("Cancel").href(urlProvider(LoginUrls.class).getLoginURL(doneURL)) %> 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 061a708bc82..3a0ea0ccfc6 100644 --- a/core/src/org/labkey/core/view/template/bootstrap/header.jsp +++ b/core/src/org/labkey/core/view/template/bootstrap/header.jsp @@ -154,7 +154,7 @@