From f0b33018ba541d4bd7073d6fd4e1505a4fe9973a Mon Sep 17 00:00:00 2001 From: lum Date: Fri, 11 Sep 2026 15:09:01 -0700 Subject: [PATCH 1/7] container scoping fixes for reports and visit maps --- .../query/reports/ReportServiceImpl.java | 18 +++-- .../study/controllers/StudyController.java | 9 ++- .../reports/ReportsController.java | 66 ++++++++++++++-- .../test/tests/study/SharedStudyTest.java | 75 ++++++++++++++++++- 4 files changed, 155 insertions(+), 13 deletions(-) diff --git a/query/src/org/labkey/query/reports/ReportServiceImpl.java b/query/src/org/labkey/query/reports/ReportServiceImpl.java index 0a5cdd83119..b33a528e012 100644 --- a/query/src/org/labkey/query/reports/ReportServiceImpl.java +++ b/query/src/org/labkey/query/reports/ReportServiceImpl.java @@ -24,6 +24,7 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Logger; import org.apache.xmlbeans.XmlObject; import org.jetbrains.annotations.NotNull; @@ -438,9 +439,15 @@ private ReportDB _saveDbReport(User user, Container c, String key, ReportDescrip else throw new RuntimeException("Can't save a report that is not stored in the database!"); - boolean reportExists = null != reportId && reportExists(reportId.getRowId()); + // A descriptor's reportId can come straight from client input, so it may name a row in any container. + ReportDB existing = null != reportId ? getReportDB(reportId.getRowId()) : null; + if (null != existing && !c.getId().equals(existing.getContainerId())) + throw new UnauthorizedException("A report can only be saved from the folder that it belongs to."); + + boolean reportExists = null != existing; if (reportExists) - reportDB = Table.update(user, getTable(), reportDB, reportId.getRowId()); + reportDB = Table.update(user, getTable(), reportDB, reportId.getRowId(), + new SimpleFilter(FieldKey.fromParts("ContainerId"), c.getId()), Level.WARN); else reportDB = Table.insert(user, getTable(), reportDB); @@ -815,12 +822,11 @@ public List getGlobalItemFilterTypes() return null; } - private boolean reportExists(int reportId) + /** Unscoped by container on purpose: callers need the row's own container to decide whether they may touch it. */ + private @Nullable ReportDB getReportDB(int reportId) { SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("RowId"), reportId); - ReportDB report = new TableSelector(getTable(), filter, null).getObject(ReportDB.class); - - return (report != null); + return new TableSelector(getTable(), filter, null).getObject(ReportDB.class); } @Nullable diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 344e5ed3b20..a0ee5c57a23 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1320,14 +1320,21 @@ public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindExcepti @Override public void validateCommand(ImportVisitMapForm form, Errors errors) { + StudyImpl study = getStudyThrowIfNull(); + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) + errors.reject(null, "Can't import visits into a study with shared visits"); } @Override public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception { + StudyImpl study = getStudyThrowIfNull(); + redirectToSharedVisitStudy(study, getViewContext().getActionURL()); + VisitMapImporter importer = new VisitMapImporter(); List errorMsg = new LinkedList<>(); - if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) + if (!importer.process(getUser(), study, form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) { for (String error : errorMsg) errors.reject("uploadVisitMap", error); diff --git a/study/src/org/labkey/study/controllers/reports/ReportsController.java b/study/src/org/labkey/study/controllers/reports/ReportsController.java index 8c1b6736add..5ecec32c213 100644 --- a/study/src/org/labkey/study/controllers/reports/ReportsController.java +++ b/study/src/org/labkey/study/controllers/reports/ReportsController.java @@ -17,6 +17,7 @@ package org.labkey.study.controllers.reports; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.Nullable; import org.json.JSONArray; @@ -34,7 +35,11 @@ import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.Container; +import org.labkey.api.data.CoreSchema; import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableSelector; +import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryParam; import org.labkey.api.query.QueryService; import org.labkey.api.query.QuerySettings; @@ -44,11 +49,7 @@ import org.labkey.api.reports.Report; import org.labkey.api.reports.ReportService; import org.labkey.api.reports.report.QueryReport; -import org.labkey.api.security.permissions.AbstractContainerScopingTest; -import org.labkey.api.security.roles.ReaderRole; -import org.labkey.api.writer.DefaultContainerUser; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.Test; +import org.labkey.api.reports.report.ReportDB; import org.labkey.api.reports.report.ReportDescriptor; import org.labkey.api.reports.report.ReportIdentifier; import org.labkey.api.reports.report.ReportUrls; @@ -59,9 +60,12 @@ import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.User; import org.labkey.api.security.permissions.AbstractActionPermissionTest; +import org.labkey.api.security.permissions.AbstractContainerScopingTest; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.study.Dataset; import org.labkey.api.study.Study; import org.labkey.api.study.StudyService; @@ -86,6 +90,7 @@ import org.labkey.api.view.ViewForm; import org.labkey.api.view.WebPartView; import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.DefaultContainerUser; import org.labkey.study.StudyModule; import org.labkey.study.StudySchema; import org.labkey.study.controllers.BaseStudyController; @@ -101,6 +106,7 @@ import org.springframework.validation.BindException; import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.mvc.Controller; import java.util.ArrayList; import java.util.Collection; @@ -1346,6 +1352,56 @@ public void testShowReportRequiresReadPermission() throws Exception assertNotEquals("The report owner must pass the read check, not be blocked at 403", HttpServletResponse.SC_FORBIDDEN, get(url, getAdmin()).getStatus()); } + + // GH Issue 1398 + @Test + public void testOverwritingSavedReports() throws Exception + { + assertReportNotStolen(SaveReportAction.class, false); + // shareReport leaves the descriptor's owner null, so the save is checked as a shared report and the + // caller's Editor role in their own folder satisfies the only permission the save path consults. + assertReportNotStolen(SaveReportViewAction.class, true); + } + + /** + * Both save actions seed the descriptor from the client-supplied params string, which ReportDescriptor copies + * verbatim into its property map -- reportId included -- so the save targets a row of the caller's choosing. + * The shared path then authorizes against the request container and updates by primary key alone. + */ + private void assertReportNotStolen(Class action, boolean shareReport) throws Exception + { + Container attackerFolder = createContainer("A"); + Container victimFolder = createContainer("B"); + + // Shared (no descriptor owner), the case the save path treats as editable by any container Editor. + Report victimReport = ReportService.get().createReportInstance(QueryReport.TYPE); + victimReport.getDescriptor().setReportName("scoping-victim-report"); + int victimRowId = ReportService.get() + .saveReportEx(new DefaultContainerUser(victimFolder, getAdmin()), "scoping-victim-key", victimReport) + .getRowId(); + + User attacker = createUserInRole(attackerFolder, EditorRole.class); + + ActionURL url = new ActionURL(action, attackerFolder) + .addParameter("reportType", QueryReport.TYPE) + .addParameter("label", "scoping-stolen-report") + .addParameter("params", "reportId=db%3A" + victimRowId); + if (shareReport) + url.addParameter("shareReport", "true"); + + post(url, attacker); + + // Read the row itself rather than ReportService: a cross-container save invalidates only the request + // container's cache, so a cached read could still show the victim's report after it had been clobbered. + ReportDB row = new TableSelector(CoreSchema.getInstance().getTableInfoReport(), + new SimpleFilter(FieldKey.fromParts("RowId"), victimRowId), null).getObject(ReportDB.class); + + assertNotNull("The victim's report was deleted by a caller with no permission in its folder", row); + assertEquals("The victim's report was re-homed into a folder the caller controls", + victimFolder.getId(), row.getContainerId()); + assertEquals("The victim's report was overwritten by a caller with no permission in its folder", + "scoping-victim-key", row.getReportKey()); + } } public static class TestCase extends AbstractActionPermissionTest diff --git a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java index b50e24467c9..0edc54d585d 100644 --- a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java +++ b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java @@ -25,6 +25,10 @@ import org.junit.experimental.categories.Category; import org.labkey.api.util.FileUtil; import org.labkey.api.util.Path; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.remoteapi.query.SelectRowsResponse; +import org.labkey.remoteapi.query.Sort; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.Locators; @@ -37,23 +41,30 @@ import org.labkey.test.pages.study.DatasetDesignerPage; import org.labkey.test.pages.study.ManageVisitPage; import org.labkey.test.params.FieldKey; +import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.Crawler; import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.Ext4Helper; import org.labkey.test.util.Maps; +import org.labkey.test.util.PermissionsHelper; +import org.labkey.test.util.SimpleHttpRequest; +import org.labkey.test.util.SimpleHttpResponse; import org.labkey.test.util.StudyHelper; import org.labkey.test.util.TestDataGenerator; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.labkey.test.util.PermissionsHelper.FOLDER_ADMIN_ROLE; import static org.labkey.test.util.PermissionsHelper.READER_ROLE; @Category({Daily.class}) @@ -71,6 +82,7 @@ public class SharedStudyTest extends BaseWebDriverTest private static final String[] STUDY2_PTIDS = {"9000", "9001"}; public static final File STUDY_DIR = TestFileUtils.getSampleData("studies/ExtraKeyStudy"); private static final String user = "study_reader@sharedstudy.test"; + private static final String visitAdminUser = "visit_admin@sharedstudy.test"; public static final String PARTICIPANT_NOUN_PLURAL = "Pandas"; public static final String PARTICIPANT_NOUN_SINGULAR = "Panda"; @@ -97,7 +109,7 @@ protected BrowserType bestBrowser() protected void doCleanup(boolean afterTest) throws TestTimeoutException { super.doCleanup(afterTest); - _userHelper.deleteUsers(false, user); + _userHelper.deleteUsers(false, user, visitAdminUser); } @BeforeClass @@ -207,6 +219,67 @@ public void testManageVisitsRedirect() Assert.assertTrue("Expected title to start with 'Manage Shared Timepoints', got:" + title, title.startsWith("Manage Shared Visits")); } + // GH Issue 1450: shared visits live in the project, so a subfolder admin must not be able to import over them + @Test + public void testImportVisitMapDeniedInSubfolder() + { + String studyPath = getProjectName() + "/" + STUDY1; + String visitMap = """ + + + + """; + + log("Grant admin in the subfolder only, leaving the project alone"); + _userHelper.createUser(visitAdminUser); + new ApiPermissionsHelper(this).addMemberToRole(visitAdminUser, FOLDER_ADMIN_ROLE, PermissionsHelper.MemberType.user, studyPath); + List visitsBeforeImport = getProjectVisitLabels(); + + log("Post a visit map to the subfolder, bypassing the form's redirect to the project"); + clickFolder(STUDY1); + impersonate(visitAdminUser); + SimpleHttpResponse response = postVisitMap(studyPath, visitMap); + stopImpersonating(); + + // The request follows redirects into a fresh session, so its status says nothing; the visits are the real check + assertEquals("Visit map import from a subfolder rewrote the project's shared visits (HTTP " + response.getResponseCode() + ")", + visitsBeforeImport, getProjectVisitLabels()); + } + + private SimpleHttpResponse postVisitMap(String containerPath, String visitMap) + { + SimpleHttpRequest request = new SimpleHttpRequest(WebTestHelper.buildURL("study", containerPath, "importVisitMap", + Map.of("content", visitMap)), "POST"); + request.copySession(getDriver()); // post as the impersonated user; carries the CSRF token + request.clearLogin(); // rely solely on the impersonated session, not admin basic-auth + + try + { + return request.getResponse(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + private List getProjectVisitLabels() + { + SelectRowsCommand command = new SelectRowsCommand("study", "Visit"); + command.setColumns(List.of("Label")); + command.setSorts(List.of(new Sort("SequenceNumMin"))); + + try + { + SelectRowsResponse response = command.execute(createDefaultConnection(), getProjectName()); + return response.getRows().stream().map(row -> String.valueOf(row.get("Label"))).toList(); + } + catch (IOException | CommandException e) + { + throw new RuntimeException(e); + } + } + @Test public void testDataspacePublishButtonVisibility() { From 3b278f1597b473592c6832c0fad6a09d4c130406 Mon Sep 17 00:00:00 2001 From: Lum Date: Mon, 14 Sep 2026 14:37:11 -0700 Subject: [PATCH 2/7] respect dataset level security when deleting rows --- study/src/org/labkey/study/StudyModule.java | 1 + .../study/controllers/StudyController.java | 83 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/study/src/org/labkey/study/StudyModule.java b/study/src/org/labkey/study/StudyModule.java index c7cbc3a5026..5c13ae7866d 100644 --- a/study/src/org/labkey/study/StudyModule.java +++ b/study/src/org/labkey/study/StudyModule.java @@ -773,6 +773,7 @@ public WebPartView getWebPartView(@NotNull ViewContext portalCtx, @NotNull Po PublishConfirmContainerScopingTest.class, CreateChildStudyAction.ContainerScopingTestCase.class, StudyController.ContainerScopingTestCase.class, + StudyController.DatasetPermissionsTestCase.class, ReportsController.ContainerScopingTestCase.class); } diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index a0ee5c57a23..a9a16ccdf61 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -20,6 +20,7 @@ import com.google.common.cache.CacheBuilder; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.apache.commons.beanutils.ConversionException; import org.apache.commons.collections4.FactoryUtils; @@ -35,6 +36,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONObject; +import org.junit.Before; import org.junit.Test; import org.labkey.api.action.ApiJsonForm; import org.labkey.api.action.ApiResponse; @@ -159,6 +161,7 @@ import org.labkey.api.reports.report.ReportUrls; import org.labkey.api.search.SearchService; import org.labkey.api.search.SearchUrls; +import org.labkey.api.security.MutableSecurityPolicy; import org.labkey.api.security.RequiresAllOf; import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresNoPermission; @@ -175,6 +178,10 @@ import org.labkey.api.security.permissions.QCAnalystPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.ReaderRole; +import org.labkey.api.security.roles.RestrictedReaderRole; +import org.labkey.api.security.roles.Role; import org.labkey.api.specimen.SpecimenManager; import org.labkey.api.specimen.SpecimenMigrationService; import org.labkey.api.specimen.location.LocationImpl; @@ -3067,9 +3074,15 @@ public class DeletePublishedRowsAction extends FormHandlerAction role) + { + MutableSecurityPolicy policy = new MutableSecurityPolicy(_dataset); + policy.addRoleAssignment(_user, role); + _dataset.savePolicy(policy, getAdmin()); + } + + private DatasetDefinition createDataset(String name) + { + StudyManager manager = StudyManager.getInstance(); + manager.createDatasetDefinition(getAdmin(), _folder, DATASET_ID); + + DatasetDefinition def = manager.getDatasetDefinition(_study, DATASET_ID).createMutable(); + def.setName(name); + def.setLabel(name); + + String domainURI = manager.getDomainURI(_folder, getAdmin(), def); + def.setTypeURI(domainURI); + OntologyManager.ensureDomainDescriptor(domainURI, name, _folder); + manager.updateDatasetDefinition(getAdmin(), def); + + return manager.getDatasetDefinition(_study, DATASET_ID); + } + } } From d2ce08c976e204d96667dc3076b246b713c23b63 Mon Sep 17 00:00:00 2001 From: Lum Date: Wed, 16 Sep 2026 11:28:41 -0700 Subject: [PATCH 3/7] code review feedback --- .../labkey/query/reports/ReportServiceImpl.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/reports/ReportServiceImpl.java b/query/src/org/labkey/query/reports/ReportServiceImpl.java index b33a528e012..c8e8da74e63 100644 --- a/query/src/org/labkey/query/reports/ReportServiceImpl.java +++ b/query/src/org/labkey/query/reports/ReportServiceImpl.java @@ -390,10 +390,15 @@ public boolean tryValidateReportPermissions(ContainerUser context, Report report } else { - if (report.canEdit(context.getUser(), context.getContainer(), errors)) + // A descriptor's reportId can come straight from client input, so its owner and creator describe the save, + // not the row that save would overwrite. Authorize against the stored report whenever there is one. + Report stored = getStoredReport(descriptor.getReportId()); + Report toCheck = null != stored ? stored : report; + + if (toCheck.canEdit(context.getUser(), context.getContainer(), errors)) { if (descriptor.isShared()) - report.canShare(context.getUser(), context.getContainer(), errors); + toCheck.canShare(context.getUser(), context.getContainer(), errors); } } @@ -829,6 +834,12 @@ public List getGlobalItemFilterTypes() return new TableSelector(getTable(), filter, null).getObject(ReportDB.class); } + /** The persisted report a descriptor's reportId names, or null if it names no database row. */ + private @Nullable Report getStoredReport(@Nullable ReportIdentifier reportId) + { + return reportId instanceof DbReportIdentifier dbReportId ? _getInstance(getReportDB(dbReportId.getRowId())) : null; + } + @Nullable private Report _deserialize(Container container, User user, XmlObject reportXml) throws IOException, XmlValidationException { From e72bb8cf466cbd94ac3d30af6557d27df9efa149 Mon Sep 17 00:00:00 2001 From: Lum Date: Wed, 16 Sep 2026 13:46:49 -0700 Subject: [PATCH 4/7] revert visit map fixes --- .../study/controllers/StudyController.java | 9 +-- .../test/tests/study/SharedStudyTest.java | 75 +------------------ 2 files changed, 2 insertions(+), 82 deletions(-) diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index a9a16ccdf61..644b6b2cf10 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1327,21 +1327,14 @@ public ModelAndView getView(ImportVisitMapForm form, boolean reshow, BindExcepti @Override public void validateCommand(ImportVisitMapForm form, Errors errors) { - StudyImpl study = getStudyThrowIfNull(); - Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); - if (sharedStudy != null && sharedStudy.getShareVisitDefinitions()) - errors.reject(null, "Can't import visits into a study with shared visits"); } @Override public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception { - StudyImpl study = getStudyThrowIfNull(); - redirectToSharedVisitStudy(study, getViewContext().getActionURL()); - VisitMapImporter importer = new VisitMapImporter(); List errorMsg = new LinkedList<>(); - if (!importer.process(getUser(), study, form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) + if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) { for (String error : errorMsg) errors.reject("uploadVisitMap", error); diff --git a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java index 0edc54d585d..b50e24467c9 100644 --- a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java +++ b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java @@ -25,10 +25,6 @@ import org.junit.experimental.categories.Category; import org.labkey.api.util.FileUtil; import org.labkey.api.util.Path; -import org.labkey.remoteapi.CommandException; -import org.labkey.remoteapi.query.SelectRowsCommand; -import org.labkey.remoteapi.query.SelectRowsResponse; -import org.labkey.remoteapi.query.Sort; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.Locators; @@ -41,30 +37,23 @@ import org.labkey.test.pages.study.DatasetDesignerPage; import org.labkey.test.pages.study.ManageVisitPage; import org.labkey.test.params.FieldKey; -import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.Crawler; import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.Ext4Helper; import org.labkey.test.util.Maps; -import org.labkey.test.util.PermissionsHelper; -import org.labkey.test.util.SimpleHttpRequest; -import org.labkey.test.util.SimpleHttpResponse; import org.labkey.test.util.StudyHelper; import org.labkey.test.util.TestDataGenerator; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -import static org.labkey.test.util.PermissionsHelper.FOLDER_ADMIN_ROLE; import static org.labkey.test.util.PermissionsHelper.READER_ROLE; @Category({Daily.class}) @@ -82,7 +71,6 @@ public class SharedStudyTest extends BaseWebDriverTest private static final String[] STUDY2_PTIDS = {"9000", "9001"}; public static final File STUDY_DIR = TestFileUtils.getSampleData("studies/ExtraKeyStudy"); private static final String user = "study_reader@sharedstudy.test"; - private static final String visitAdminUser = "visit_admin@sharedstudy.test"; public static final String PARTICIPANT_NOUN_PLURAL = "Pandas"; public static final String PARTICIPANT_NOUN_SINGULAR = "Panda"; @@ -109,7 +97,7 @@ protected BrowserType bestBrowser() protected void doCleanup(boolean afterTest) throws TestTimeoutException { super.doCleanup(afterTest); - _userHelper.deleteUsers(false, user, visitAdminUser); + _userHelper.deleteUsers(false, user); } @BeforeClass @@ -219,67 +207,6 @@ public void testManageVisitsRedirect() Assert.assertTrue("Expected title to start with 'Manage Shared Timepoints', got:" + title, title.startsWith("Manage Shared Visits")); } - // GH Issue 1450: shared visits live in the project, so a subfolder admin must not be able to import over them - @Test - public void testImportVisitMapDeniedInSubfolder() - { - String studyPath = getProjectName() + "/" + STUDY1; - String visitMap = """ - - - - """; - - log("Grant admin in the subfolder only, leaving the project alone"); - _userHelper.createUser(visitAdminUser); - new ApiPermissionsHelper(this).addMemberToRole(visitAdminUser, FOLDER_ADMIN_ROLE, PermissionsHelper.MemberType.user, studyPath); - List visitsBeforeImport = getProjectVisitLabels(); - - log("Post a visit map to the subfolder, bypassing the form's redirect to the project"); - clickFolder(STUDY1); - impersonate(visitAdminUser); - SimpleHttpResponse response = postVisitMap(studyPath, visitMap); - stopImpersonating(); - - // The request follows redirects into a fresh session, so its status says nothing; the visits are the real check - assertEquals("Visit map import from a subfolder rewrote the project's shared visits (HTTP " + response.getResponseCode() + ")", - visitsBeforeImport, getProjectVisitLabels()); - } - - private SimpleHttpResponse postVisitMap(String containerPath, String visitMap) - { - SimpleHttpRequest request = new SimpleHttpRequest(WebTestHelper.buildURL("study", containerPath, "importVisitMap", - Map.of("content", visitMap)), "POST"); - request.copySession(getDriver()); // post as the impersonated user; carries the CSRF token - request.clearLogin(); // rely solely on the impersonated session, not admin basic-auth - - try - { - return request.getResponse(); - } - catch (IOException e) - { - throw new RuntimeException(e); - } - } - - private List getProjectVisitLabels() - { - SelectRowsCommand command = new SelectRowsCommand("study", "Visit"); - command.setColumns(List.of("Label")); - command.setSorts(List.of(new Sort("SequenceNumMin"))); - - try - { - SelectRowsResponse response = command.execute(createDefaultConnection(), getProjectName()); - return response.getRows().stream().map(row -> String.valueOf(row.get("Label"))).toList(); - } - catch (IOException | CommandException e) - { - throw new RuntimeException(e); - } - } - @Test public void testDataspacePublishButtonVisibility() { From 813899c5277f3163359cfca5f362e9c7db735861 Mon Sep 17 00:00:00 2001 From: Lum Date: Thu, 17 Sep 2026 09:52:54 -0700 Subject: [PATCH 5/7] More targeted fix for visit map imports from shared study folders. --- .../study/controllers/StudyController.java | 15 +++- .../test/tests/study/SharedStudyTest.java | 76 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 644b6b2cf10..7e83bfef59a 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1332,9 +1332,15 @@ public void validateCommand(ImportVisitMapForm form, Errors errors) @Override public boolean handlePost(ImportVisitMapForm form, BindException errors) throws Exception { + // GH Issue 1450: For shared studies, don't allow visit maps to be imported from a subfolder + StudyImpl study = getStudyThrowIfNull(); + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) + throw new UnauthorizedException("Visit map import must is only allowed from the shared study root."); + VisitMapImporter importer = new VisitMapImporter(); List errorMsg = new LinkedList<>(); - if (!importer.process(getUser(), getStudyThrowIfNull(), form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) + if (!importer.process(getUser(), study, form.getContent(), VisitMapImporter.Format.Xml, errorMsg, _log)) { for (String error : errorMsg) errors.reject("uploadVisitMap", error); @@ -2543,6 +2549,13 @@ public void validateForm(VisitForm form, Errors errors) } Study study = getStudy(getContainer()); + Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); + if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) + { + errors.reject(ERROR_MSG, "Can't create visits in a study with shared visits"); + return; + } + boolean isDateBased = study.getTimepointType() == TimepointType.DATE; form.validate(errors, study); diff --git a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java index b50e24467c9..7138c81eaf2 100644 --- a/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java +++ b/study/test/src/org/labkey/test/tests/study/SharedStudyTest.java @@ -16,6 +16,7 @@ package org.labkey.test.tests.study; import org.apache.commons.lang3.ArrayUtils; +import org.apache.hc.core5.http.HttpStatus; import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Before; @@ -25,6 +26,10 @@ import org.junit.experimental.categories.Category; import org.labkey.api.util.FileUtil; import org.labkey.api.util.Path; +import org.labkey.remoteapi.CommandException; +import org.labkey.remoteapi.query.SelectRowsCommand; +import org.labkey.remoteapi.query.SelectRowsResponse; +import org.labkey.remoteapi.query.Sort; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.Locators; @@ -37,23 +42,30 @@ import org.labkey.test.pages.study.DatasetDesignerPage; import org.labkey.test.pages.study.ManageVisitPage; import org.labkey.test.params.FieldKey; +import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.Crawler; import org.labkey.test.util.DataRegionTable; import org.labkey.test.util.Ext4Helper; import org.labkey.test.util.Maps; +import org.labkey.test.util.PermissionsHelper; +import org.labkey.test.util.SimpleHttpRequest; +import org.labkey.test.util.SimpleHttpResponse; import org.labkey.test.util.StudyHelper; import org.labkey.test.util.TestDataGenerator; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.labkey.test.util.PermissionsHelper.FOLDER_ADMIN_ROLE; import static org.labkey.test.util.PermissionsHelper.READER_ROLE; @Category({Daily.class}) @@ -71,6 +83,7 @@ public class SharedStudyTest extends BaseWebDriverTest private static final String[] STUDY2_PTIDS = {"9000", "9001"}; public static final File STUDY_DIR = TestFileUtils.getSampleData("studies/ExtraKeyStudy"); private static final String user = "study_reader@sharedstudy.test"; + private static final String visitAdminUser = "visit_admin@sharedstudy.test"; public static final String PARTICIPANT_NOUN_PLURAL = "Pandas"; public static final String PARTICIPANT_NOUN_SINGULAR = "Panda"; @@ -97,7 +110,7 @@ protected BrowserType bestBrowser() protected void doCleanup(boolean afterTest) throws TestTimeoutException { super.doCleanup(afterTest); - _userHelper.deleteUsers(false, user); + _userHelper.deleteUsers(false, user, visitAdminUser); } @BeforeClass @@ -207,6 +220,67 @@ public void testManageVisitsRedirect() Assert.assertTrue("Expected title to start with 'Manage Shared Timepoints', got:" + title, title.startsWith("Manage Shared Visits")); } + // GH Issue 1450: shared visits live in the project, so a subfolder admin must not be able to import over them + @Test + public void testImportVisitMapDeniedInSubfolder() + { + String studyPath = getProjectName() + "/" + STUDY1; + String visitMap = """ + + + + """; + + log("Grant admin in the subfolder only, leaving the project alone"); + _userHelper.createUser(visitAdminUser); + new ApiPermissionsHelper(this).addMemberToRole(visitAdminUser, FOLDER_ADMIN_ROLE, PermissionsHelper.MemberType.user, studyPath); + List visitsBeforeImport = getProjectVisitLabels(); + + log("Post a visit map to the subfolder, bypassing the form's redirect to the project"); + clickFolder(STUDY1); + impersonate(visitAdminUser); + SimpleHttpResponse response = postVisitMap(studyPath, visitMap); + stopImpersonating(); + + assertEquals("Expected the import to be refused", HttpStatus.SC_FORBIDDEN, response.getResponseCode()); + assertEquals("Visit map import from a subfolder rewrote the project's shared visits", + visitsBeforeImport, getProjectVisitLabels()); + } + + private SimpleHttpResponse postVisitMap(String containerPath, String visitMap) + { + SimpleHttpRequest request = new SimpleHttpRequest(WebTestHelper.buildURL("study", containerPath, "importVisitMap", + Map.of("content", visitMap)), "POST"); + request.copySession(getDriver()); // post as the impersonated user; carries the CSRF token + request.clearLogin(); // rely solely on the impersonated session, not admin basic-auth + + try + { + return request.getResponse(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + private List getProjectVisitLabels() + { + SelectRowsCommand command = new SelectRowsCommand("study", "Visit"); + command.setColumns(List.of("Label")); + command.setSorts(List.of(new Sort("SequenceNumMin"))); + + try + { + SelectRowsResponse response = command.execute(createDefaultConnection(), getProjectName()); + return response.getRows().stream().map(row -> String.valueOf(row.get("Label"))).toList(); + } + catch (IOException | CommandException e) + { + throw new RuntimeException(e); + } + } + @Test public void testDataspacePublishButtonVisibility() { From 012cf81c17323f652827ead06feda0f06d4888d7 Mon Sep 17 00:00:00 2001 From: Karl Lum Date: Fri, 18 Sep 2026 11:14:50 -0700 Subject: [PATCH 6/7] Update study/src/org/labkey/study/controllers/StudyController.java Co-authored-by: Cory Nathe --- study/src/org/labkey/study/controllers/StudyController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/study/src/org/labkey/study/controllers/StudyController.java b/study/src/org/labkey/study/controllers/StudyController.java index 7e83bfef59a..b27ba967f57 100644 --- a/study/src/org/labkey/study/controllers/StudyController.java +++ b/study/src/org/labkey/study/controllers/StudyController.java @@ -1336,7 +1336,7 @@ public boolean handlePost(ImportVisitMapForm form, BindException errors) throws StudyImpl study = getStudyThrowIfNull(); Study sharedStudy = StudyManager.getInstance().getSharedStudy(study); if (sharedStudy != null && sharedStudy.getShareVisitDefinitions() == Boolean.TRUE) - throw new UnauthorizedException("Visit map import must is only allowed from the shared study root."); + throw new UnauthorizedException("Visit map import is only allowed from the shared study root."); VisitMapImporter importer = new VisitMapImporter(); List errorMsg = new LinkedList<>(); From a9bb194e0f587fde536d871a00c143f04e511c0b Mon Sep 17 00:00:00 2001 From: lum Date: Fri, 18 Sep 2026 11:48:55 -0700 Subject: [PATCH 7/7] additional container check --- .../query/reports/ReportServiceImpl.java | 2239 +++++++++-------- 1 file changed, 1122 insertions(+), 1117 deletions(-) diff --git a/query/src/org/labkey/query/reports/ReportServiceImpl.java b/query/src/org/labkey/query/reports/ReportServiceImpl.java index c8e8da74e63..f489d172e3c 100644 --- a/query/src/org/labkey/query/reports/ReportServiceImpl.java +++ b/query/src/org/labkey/query/reports/ReportServiceImpl.java @@ -1,1117 +1,1122 @@ -/* - * Copyright (c) 2008-2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.query.reports; - -import org.apache.commons.beanutils.BeanUtils; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.collections4.MultiSet; -import org.apache.commons.collections4.multiset.HashMultiSet; -import org.apache.commons.io.FileUtils; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Strings; -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.Logger; -import org.apache.xmlbeans.XmlObject; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.admin.FolderExportContext; -import org.labkey.api.admin.FolderImportContext; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.collections.MultiSetUtils; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.CoreSchema; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.SqlExecutor; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.module.Module; -import org.labkey.api.moduleeditor.api.ModuleEditorService; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.ValidationError; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reports.Report; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.model.ReportPropsManager; -import org.labkey.api.reports.model.ViewCategory; -import org.labkey.api.reports.model.ViewCategoryListener; -import org.labkey.api.reports.model.ViewCategoryManager; -import org.labkey.api.reports.report.AbstractReportIdentifier; -import org.labkey.api.reports.report.DbReportIdentifier; -import org.labkey.api.reports.report.ModuleJavaScriptReportDescriptor; -import org.labkey.api.reports.report.ModuleReportDescriptor; -import org.labkey.api.reports.report.ModuleReportIdentifier; -import org.labkey.api.reports.report.ReportDB; -import org.labkey.api.reports.report.ReportDescriptor; -import org.labkey.api.reports.report.ReportIdentifier; -import org.labkey.api.reports.report.ReportIdentifierConverter; -import org.labkey.api.reports.report.ScriptEngineReport; -import org.labkey.api.reports.report.ScriptReportDescriptor; -import org.labkey.api.reports.report.python.ModuleIpynbReportDescriptor; -import org.labkey.api.reports.report.r.ModuleRReportDescriptor; -import org.labkey.api.reports.report.r.RReportDescriptor; -import org.labkey.api.reports.report.view.ReportUtil; -import org.labkey.api.security.MutableSecurityPolicy; -import org.labkey.api.security.SecurityPolicyManager; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.study.Dataset; -import org.labkey.api.study.Study; -import org.labkey.api.study.StudyService; -import org.labkey.api.usageMetrics.UsageMetricsService; -import org.labkey.api.util.ContainerUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.StringUtilsLabKey; -import org.labkey.api.util.SystemMaintenance; -import org.labkey.api.util.SystemMaintenance.MaintenanceTask; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.util.XmlBeansUtil; -import org.labkey.api.util.XmlValidationException; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.ViewContext; -import org.labkey.api.visualization.GenericChartReport; -import org.labkey.api.visualization.VisualizationReportDescriptor; -import org.labkey.api.writer.ContainerUser; -import org.labkey.api.writer.DefaultContainerUser; -import org.labkey.api.writer.VirtualFile; -import org.labkey.query.xml.ReportDescriptorDocument; -import org.labkey.query.xml.ReportDescriptorType; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.StringWriter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; - -import static org.labkey.api.reports.report.ScriptReportDescriptor.REPORT_METADATA_EXTENSION; - -public class ReportServiceImpl implements ContainerManager.ContainerListener, ReportService -{ - private static final Logger _log = LogHelper.getLogger(ReportService.class, "Errors and warnings with reports"); - private static final List _uiProviders = new CopyOnWriteArrayList<>(); - private static final Map _typeToProviderMap = new ConcurrentHashMap<>(); - private static final List _globalItemFilterTypes = new CopyOnWriteArrayList<>(); - - /** - * maps descriptor types to providers - */ - private final Map> _descriptors = new ConcurrentHashMap<>(); - - /** - * maps report types to implementations - */ - private final Map> _reports = new ConcurrentHashMap<>(); - - private final static ReportServiceImpl INSTANCE = new ReportServiceImpl(); - - public static ReportServiceImpl getInstance() - { - return INSTANCE; - } - - private ReportServiceImpl() - { - ContainerManager.addContainerListener(this); - ContainerManager.addSecurableResourceProvider((c, u) -> { - List ret = new ArrayList<>(); - for (Report report : ReportService.get().getReports(u, c)) - { - if (report.getDescriptor().hasPermission(u, AdminPermission.class)) - ret.add(report.getDescriptor()); - } - return ret; - }); - ConvertUtils.register(new ReportIdentifierConverter(), ReportIdentifier.class); - ReportQueryChangeListener listener = new ReportQueryChangeListener(); - QueryService.get().addQueryListener(listener); - QueryService.get().addCustomViewListener(listener); - SystemMaintenance.addTask(new ReportServiceMaintenanceTask()); - ViewCategoryManager.addCategoryListener(new CategoryListener(this)); - } - - @Override - public void registerDescriptor(ReportDescriptor descriptor) - { - if (descriptor == null) - throw new IllegalArgumentException("Invalid descriptor instance"); - - if (null != _descriptors.putIfAbsent(descriptor.getDescriptorType(), descriptor.getClass())) - _log.warn("Descriptor type : {} has previously been registered.", descriptor.getDescriptorType()); - } - - @Override - public ReportDescriptor createDescriptorInstance(String typeName) - { - if (typeName == null) - { - _log.error("createDescriptorInstance : typeName cannot be null"); - return null; - } - Class clazz = _descriptors.get(typeName); - - if (null == clazz) - return null; - - try - { - if (ReportDescriptor.class.isAssignableFrom(clazz)) - { - return (ReportDescriptor)clazz.getDeclaredConstructor().newInstance(); - } - - throw new IllegalArgumentException("The specified class: " + clazz.getName() + " is not an instance of ReportDescriptor"); - } - catch (Exception e) - { - throw new IllegalArgumentException("The specified class could not be created: " + clazz.getName()); - } - } - - @Override - @Nullable - public ReportDescriptor getModuleReportDescriptor(Module module, String path) - { - return ModuleReportCache.getModuleReportDescriptor(module, path); - } - - @Override - @NotNull - public List getModuleReportDescriptors(Module module, @Nullable String path) - { - return ModuleReportCache.getModuleReportDescriptors(module, path); - } - - @Override - public void registerReport(Report report) - { - if (report == null) - throw new IllegalArgumentException("Invalid report instance"); - - if (null != _reports.putIfAbsent(report.getType(), report.getClass())) - _log.warn("Report type : {} has previously been registered.", report.getType()); - } - - @Override - @Nullable - public Report createReportInstance(String typeName) - { - // ConcurrentHashMap doesn't support null keys, so do the extra check ourselves - if (typeName == null) - { - return null; - } - - Class clazz = _reports.get(typeName); - - if (null == clazz) - return null; - - try - { - if (Report.class.isAssignableFrom(clazz)) - { - Report report = (Report)clazz.getDeclaredConstructor().newInstance(); - report.getDescriptor().setReportType(typeName); - - return report; - } - - throw new IllegalArgumentException("The specified class: " + clazz.getName() + " is not an instance of Report"); - } - catch (Exception e) - { - throw new IllegalArgumentException("The specified class could not be created: " + clazz.getName()); - } - } - - @Override - @Nullable - public Report createReportInstance(ReportDescriptor descriptor) - { - Report report = createReportInstance(descriptor.getReportType()); - report.setDescriptor(descriptor); - return report; - } - - private static TableInfo getTable() - { - return CoreSchema.getInstance().getTableInfoReport(); - } - - @Override - public void containerDeleted(Container c, User user) - { - ContainerUtil.purgeTable(getTable(), c, "ContainerId"); - DatabaseReportCache.uncache(c); - } - - @Override - public Report createFromQueryString(String queryString) - { - for (Pair param : PageFlowUtil.fromQueryString(queryString)) - { - if (ReportDescriptor.Prop.reportType.toString().equals(param.getKey())) - { - if (param.getValue() != null) - { - Report report = createReportInstance(param.getValue()); - report.getDescriptor().initFromQueryString(queryString); - return report; - } - } - } - return null; - } - - @Override - public void deleteReport(ContainerUser context, Report report) - { - //ensure that descriptor id is a DbReportIdentifier - DbReportIdentifier reportId; - - if (report.getDescriptor().getReportId() instanceof DbReportIdentifier) - reportId = (DbReportIdentifier)(report.getDescriptor().getReportId()); - else - throw new RuntimeException("Can't delete a report that is not stored in the database!"); - - DbScope scope = getTable().getSchema().getScope(); - - try (DbScope.Transaction tx = scope.ensureTransaction()) - { - report.beforeDelete(context); - - final ReportDescriptor descriptor = report.getDescriptor(); - _deleteReport(context.getContainer(), context.getUser(), reportId.getRowId(), descriptor); - SecurityPolicyManager.deletePolicy(descriptor); - tx.commit(); - } - } - - private void _deleteReport(Container c, User u, int reportId, ReportDescriptor descriptor) - { - SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("ContainerId"), c.getId()); - filter.addCondition(FieldKey.fromParts("RowId"), reportId); - Table.delete(getTable(), filter); - DatabaseReportCache.uncache(c); - - ReportAuditProvider.ReportAuditEvent event = new ReportAuditProvider.ReportAuditEvent(reportId, descriptor, c, "Report deleted"); - AuditLogService.get().addEvent(u, event); - } - - @Override - public ReportIdentifier saveReportEx(ContainerUser context, String key, Report report, boolean skipValidation) - { - ReportIdentifier id = report.getDescriptor().getReportId(); - if (null == id || id instanceof DbReportIdentifier) - { - if (report.getDescriptor().isModuleBased()) - throw new IllegalStateException(); - int rowid = _saveDbReport(context, key, report, skipValidation).getRowId(); - return new DbReportIdentifier(rowid); - } - - ReportDescriptor descriptor = report.getDescriptor(); - - // NOTE there are module reports other than R - if (id instanceof ModuleReportIdentifier && - (descriptor instanceof ModuleRReportDescriptor || descriptor instanceof ModuleJavaScriptReportDescriptor || descriptor instanceof ModuleIpynbReportDescriptor)) - { - return _saveModuleReport(context, key, report, skipValidation); - } - else - { - throw new RuntimeException("Can't save this kind of module report yet."); - } - } - - @Override - public void validateReportPermissions(ContainerUser context, Report report) - { - List errors = new ArrayList<>(); - - tryValidateReportPermissions(context, report, errors); - - if (!errors.isEmpty()) - { - StringBuilder sb = new StringBuilder(); - for (ValidationError error : errors) - { - if (!sb.isEmpty()) - sb.append("\n"); - - sb.append(error.getMessage()); - } - - throw new UnauthorizedException(sb.toString()); - } - } - - @Override - public boolean tryValidateReportPermissions(ContainerUser context, Report report, List errors) - { - final ReportDescriptor descriptor = report.getDescriptor(); - - if (descriptor.isNew()) - { - if (descriptor.isShared()) - report.canShare(context.getUser(), context.getContainer(), errors); - } - else - { - // A descriptor's reportId can come straight from client input, so its owner and creator describe the save, - // not the row that save would overwrite. Authorize against the stored report whenever there is one. - Report stored = getStoredReport(descriptor.getReportId()); - Report toCheck = null != stored ? stored : report; - - if (toCheck.canEdit(context.getUser(), context.getContainer(), errors)) - { - if (descriptor.isShared()) - toCheck.canShare(context.getUser(), context.getContainer(), errors); - } - } - - return errors.isEmpty(); - } - - private ReportDB _saveDbReport(ContainerUser context, String key, Report report, boolean skipValidation) - { - DbScope scope = getTable().getSchema().getScope(); - ReportDescriptor descriptor; - ReportDB r; - try (DbScope.Transaction tx = scope.ensureTransaction()) - { - report.getDescriptor().setContainer(context.getContainer().getId()); - report.beforeSave(context); - - descriptor = report.getDescriptor(); - - // last chance to validate permissions, this should be done in the controller actions, so - // just throw an exception if validation fails - if (!skipValidation) - validateReportPermissions(context, report); - - r = _saveDbReport(context.getUser(), context.getContainer(), key, descriptor); - tx.commit(); - } - _saveReportProperties(context.getContainer(), r.getEntityId(), descriptor); - return r; - } - - private ReportDB _saveDbReport(User user, Container c, String key, ReportDescriptor descriptor) - { - ReportDB reportDB = new ReportDB(c, key, descriptor); - - //ensure that descriptor id is a DbReportIdentifier - DbReportIdentifier reportId; - if (null == descriptor.getReportId() || descriptor.getReportId() instanceof DbReportIdentifier) - { - reportId = (DbReportIdentifier)(descriptor.getReportId()); - if (reportId != null) - reportDB.setRowId(reportId.getRowId()); - } - else - throw new RuntimeException("Can't save a report that is not stored in the database!"); - - // A descriptor's reportId can come straight from client input, so it may name a row in any container. - ReportDB existing = null != reportId ? getReportDB(reportId.getRowId()) : null; - if (null != existing && !c.getId().equals(existing.getContainerId())) - throw new UnauthorizedException("A report can only be saved from the folder that it belongs to."); - - boolean reportExists = null != existing; - if (reportExists) - reportDB = Table.update(user, getTable(), reportDB, reportId.getRowId(), - new SimpleFilter(FieldKey.fromParts("ContainerId"), c.getId()), Level.WARN); - else - reportDB = Table.insert(user, getTable(), reportDB); - - DatabaseReportCache.uncache(c); - - ReportAuditProvider.ReportAuditEvent event = new ReportAuditProvider.ReportAuditEvent(reportDB, descriptor, c, reportExists ? "Report updated" : "Report created"); - AuditLogService.get().addEvent(user, event); - - return reportDB; - } - - private void _saveReportProperties(Container c, String entityId, ReportDescriptor descriptor) - { - // consider: make this more generic instead of picking out these specific properties - try { - if (null != descriptor.getAuthorAsObject()) - ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.author.name(), descriptor.getAuthorAsObject()); - if (null != descriptor.getStatus()) - ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.status.name(), descriptor.getStatus()); - if (null != descriptor.getRefreshDateAsObject()) - ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.refreshDate.name(), descriptor.getRefreshDateAsObject()); - } - catch (ValidationException e) { - throw new RuntimeException(e); - } - } - - - private ReportIdentifier _saveModuleReport(ContainerUser context, String key, Report report, boolean skipValidation) - { - if (!(report.getDescriptor() instanceof ModuleReportDescriptor descriptor) || !(report.getDescriptor().getReportId() instanceof ModuleReportIdentifier)) - throw new IllegalStateException("This should be a module report!"); - - User user = context.getUser(); - Container c = context.getContainer(); - - report.beforeSave(context); - - // last chance to validate permissions, this should be done in the controller actions, so - // just throw an exception if validation fails - if (!skipValidation) - validateReportPermissions(context, report); - - File scriptFile = ModuleEditorService.get().getFileForModuleResource(descriptor.getModule(), descriptor.getSourceFile().getPath()); - if (null == scriptFile || !scriptFile.canWrite()) - throw new RuntimeException("This module resource can not be edited"); // shouldn't happen - File xmlFile; - if (null != descriptor.getMetaDataFile()) - { - xmlFile = ModuleEditorService.get().getFileForModuleResource(descriptor.getModule(), descriptor.getMetaDataFile().getPath()); - } - else - { - xmlFile = new File(scriptFile.getParentFile(), descriptor.getReportName() + REPORT_METADATA_EXTENSION); - } - if (!(xmlFile.exists() ? xmlFile.isFile() && xmlFile.canWrite() : xmlFile.getParentFile().canWrite())) - throw new RuntimeException("This module resource can not be edited"); // shouldn't happen - - try - { - String script = report.getDescriptor().getProperty(ScriptReportDescriptor.Prop.script); - String reportXml; - // we want this to act like folder export (don't persist script property), not like database save, so use getDescriptorDocument(FolderExportContext) - FolderExportContext ex = new FolderExportContext(user, c, null, null, null); - ReportDescriptorDocument reportDoc = report.getDescriptor().getDescriptorDocument(ex); - try (StringWriter writer = new StringWriter()) - { - reportDoc.save(writer, XmlBeansUtil.getDefaultSaveOptions()); - reportXml = writer.toString(); - } - FileUtils.write(scriptFile, script, StringUtilsLabKey.DEFAULT_CHARSET); - FileUtils.write(xmlFile, reportXml, StringUtilsLabKey.DEFAULT_CHARSET); - } - catch (IOException x) - { - throw UnexpectedException.wrap(x); - } - - // TODO save report.xml - // _saveReportProperties(context.getContainer(), r.getEntityId(), descriptor); - - // avoid having a race between reselecting the report, and the file watcher - ModuleReportCache.uncache(descriptor.getModule()); - return descriptor.getReportId(); - } - - - public @Nullable Report _getInstance(ReportDB r) - { - if (r != null) - { - try - { - ReportDescriptor descriptor = ReportDescriptor.createFromXML(r.getDescriptorXML()); - - if (descriptor != null) - { - BeanUtils.copyProperties(descriptor, r); - descriptor.setReportId(new DbReportIdentifier(r.getRowId())); - descriptor.setOwner(r.getReportOwner()); - descriptor.setDisplayOrder(r.getDisplayOrder()); - - if (r.getCategoryId() != null) - descriptor.setCategoryId(r.getCategoryId()); - - descriptor.initProperties(); - - String type = descriptor.getReportType(); - Report report = createReportInstance(type); - if (report != null) - report.setDescriptor(descriptor); - return report; - } - } - catch (Exception e) - { - throw new RuntimeException(e); - } - } - - return null; - } - - @Override - public void setReportDisplayOrder(ContainerUser context, Report report, int displayOrder) - { - ReportIdentifier reportIdentifier = report.getDescriptor().getReportId(); - if (reportIdentifier != null && reportIdentifier.getRowId() != 0) - { - TableInfo table = getTable(); - SQLFragment sql = new SQLFragment("UPDATE ").append(table, ""); - sql.append(" SET DisplayOrder = ? WHERE RowId = ?"); - sql.addAll(displayOrder, reportIdentifier.getRowId()); - - new SqlExecutor(table.getSchema()).execute(sql); - - DatabaseReportCache.uncache(context.getContainer()); - } - } - - @Override - public Report getReportByEntityId(Container c, String entityId) - { - if (StringUtils.isBlank(entityId)) - return null; - if (GUID.isGUID(entityId)) - { - return DatabaseReportCache.getReportByEntityId(c, entityId); - } - else - { - // TODO hack: see ModuleRReportDescriptor.getEntityId() - ReportIdentifier id = getReportIdentifier(PageFlowUtil.decode(entityId), null, c); - if (null == id) - return null; - return id.getReport(new DefaultContainerUser(c, null)); - } - } - - @Override - public Report getReport(Container c, int rowId) - { - Report report = DatabaseReportCache.getReport(c, rowId); - - if (null != report) - return report; - - while (!c.isRoot()) - { - c = c.getParent(); - report = DatabaseReportCache.getReport(c, rowId); - - if (null != report) - { - if (report.getDescriptor().isInheritable()) - return report; - else - return null; - } - } - - // Look for this report in the shared project - return (!ContainerManager.getSharedContainer().equals(c)) ? DatabaseReportCache.getReport(ContainerManager.getSharedContainer(), rowId) : null; - } - - @Override - public ReportIdentifier getReportIdentifier(String reportId, @Nullable User user, @Nullable Container container) - { - return AbstractReportIdentifier.fromString(reportId, user, container); - } - - @Override - public Collection getReports(@Nullable User user, @NotNull Container c) - { - List reportsList = new ArrayList<>(DatabaseReportCache.getReports(c)); - return getSortedReadableReports(reportsList, user); - } - - @Override - public Collection getReports(@Nullable User user, @NotNull Container c, @Nullable String key) - { - List moduleReportDescriptors = new ArrayList<>(); - - for (Module module : c.getActiveModules()) - { - moduleReportDescriptors.addAll(getModuleReportDescriptors(module, key)); - } - - List reports = new ArrayList<>(); - - for (ReportDescriptor descriptor : moduleReportDescriptors) - { - String type = descriptor.getReportType(); - Report report = createReportInstance(type); - - if (report != null) - { - report.setDescriptor(descriptor); - reports.add(report); - } - } - - if (key == null) - { - reports.addAll(DatabaseReportCache.getReports(c)); - } - else - { - reports.addAll(DatabaseReportCache.getReportsByReportKey(c, key)); - } - - return getSortedReadableReports(reports, user); - } - - @Override - @Deprecated - public Collection getInheritableReports(User user, Container c, @Nullable String reportKey) - { - Collection inheritable = DatabaseReportCache.getInheritableReports(c); - - // If reportKey is specified then grab just those from the inheritable reports - if (null != reportKey) - { - inheritable = inheritable.stream() - .filter(report -> reportKey.equals(report.getDescriptor().getReportKey())) - .toList(); - } - - List reportsList = new ArrayList<>(inheritable); - - return getSortedReadableReports(reportsList, user); - } - - private static Collection getSortedReadableReports(List reports, @Nullable User user) - { - List readableReports; - - if (null == user) - { - readableReports = reports; - } - else - { - readableReports = reports - .stream() - .filter(report -> report.getDescriptor().isModuleBased() || report.hasPermission(user, report.getDescriptor().getResourceContainer(), ReadPermission.class)) - .collect(Collectors.toCollection(ArrayList::new)); - } - - // must re-sort to allow file-based reports to show in proper positions - // NOTE: currently, the only way for file-based reports to appear in the middle of a category is to share a - // displayOrder number with a report already in the cache (all indices in a range are used when - // persisting); therefore the file-based report's order can never be fully guaranteed - readableReports.sort(Comparator.comparingInt(r -> r.getDescriptor().getDisplayOrder())); - - return readableReports; - } - - @Override - @Nullable - public Report getReport(ReportDB reportDB) - { - return _getInstance(reportDB); - } - - @Override - public void addUIProvider(UIProvider provider) - { - _uiProviders.add(provider); - } - - @Override - public List getUIProviders() - { - return Collections.unmodifiableList(_uiProviders); - } - - @Override - public void addGlobalItemFilterType(String type) - { - _globalItemFilterTypes.add(type); - } - - @Override - public List getGlobalItemFilterTypes() - { - return Collections.unmodifiableList(_globalItemFilterTypes); - } - - @Override - public @NotNull String getIconPath(Report report) - { - if (report != null) - { - String reportType = report.getType(); - - UIProvider claimingProvider = _typeToProviderMap.get(reportType); - - if (null != claimingProvider) - { - String iconPath = claimingProvider.getIconPath(report); - - if (null == iconPath) - throw new IllegalStateException(reportType + " is claimed by " + claimingProvider + " but iconPath is null"); - - return iconPath; - } - - for (UIProvider provider : _uiProviders) - { - String iconPath = provider.getIconPath(report); - - if (iconPath != null) - { - _typeToProviderMap.put(reportType, provider); - return iconPath; - } - } - } - - // No UIProvider claimed this report type... so fall-back on blank image - return "/_.gif"; - } - - @Override - public @Nullable String getIconCls(Report report) - { - if (report != null) - { - String reportType = report.getType(); - - UIProvider claimingProvider = _typeToProviderMap.get(reportType); - - if (null != claimingProvider) - { - return claimingProvider.getIconCls(report); - } - - for (UIProvider provider : _uiProviders) - { - String iconClass = provider.getIconCls(report); - - if (iconClass != null) - { - _typeToProviderMap.put(reportType, provider); - return iconClass; - } - } - } - - // No report provider claimed this, so don't return an icon (we should always have an image icon to fall back on anyway) - return null; - } - - /** Unscoped by container on purpose: callers need the row's own container to decide whether they may touch it. */ - private @Nullable ReportDB getReportDB(int reportId) - { - SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("RowId"), reportId); - return new TableSelector(getTable(), filter, null).getObject(ReportDB.class); - } - - /** The persisted report a descriptor's reportId names, or null if it names no database row. */ - private @Nullable Report getStoredReport(@Nullable ReportIdentifier reportId) - { - return reportId instanceof DbReportIdentifier dbReportId ? _getInstance(getReportDB(dbReportId.getRowId())) : null; - } - - @Nullable - private Report _deserialize(Container container, User user, XmlObject reportXml) throws IOException, XmlValidationException - { - ReportDescriptor descriptor = ReportDescriptor.createFromXmlObject(container, user, reportXml); - - if (descriptor != null) - { - //descriptor.setReportId(new DbReportIdentifier(r.getRowId())); - //descriptor.setOwner(r.getReportOwner()); - - String type = descriptor.getReportType(); - Report report = createReportInstance(type); - - if (report != null) - { - report.setDescriptor(descriptor); - report.afterImport(container, user); - } - - return report; - } - - return null; - } - - @Nullable - private Report deserialize(Container container, User user, XmlObject reportXml, VirtualFile root, String xmlFileName) throws IOException, XmlValidationException - { - if (null != reportXml) - { - Report report = _deserialize(container, user, reportXml); - - // reset any report identifier, we want to treat an imported report as a new - // report instance - if (report != null) - { - ReportDescriptor descriptor = report.getDescriptor(); - descriptor.setReportId(new DbReportIdentifier(-1)); - - // if this is an R report look for report source in separate file - if (descriptor instanceof RReportDescriptor && xmlFileName.toLowerCase().endsWith(".report.xml")) - { - String baseName = xmlFileName.substring(0, xmlFileName.length() - ".report.xml".length()); - InputStream is = null; - try - { - is = root.getInputStream(baseName + ".R"); - if (null == is) - is = root.getInputStream(baseName + ".r"); - if (null != is) - { - String script = IOUtils.toString(is, StringUtilsLabKey.DEFAULT_CHARSET); - if (!StringUtils.isBlank(script)) - descriptor.setProperty(ScriptReportDescriptor.Prop.script, script); - } - } - finally - { - // not using try with resources because of trying to open .R and .r - IOUtils.closeQuietly(is); - } - } - } - - return report; - } - - throw new IllegalArgumentException("Report XML file does not exist."); - } - - @Override @Nullable - public Report importReport(FolderImportContext ctx, XmlObject reportXml, VirtualFile root, String xmlFileName) throws IOException, XmlValidationException - { - Report report = deserialize(ctx.getContainer(), ctx.getUser(), reportXml, root, xmlFileName); - if (report != null) - { - ReportDescriptor descriptor = report.getDescriptor(); - String key = descriptor.getReportKey(); - if (StringUtils.isBlank(key)) - { - // use the default key used by query views - key = ReportUtil.getReportKey(descriptor.getProperty(ReportDescriptor.Prop.schemaName), descriptor.getProperty(ReportDescriptor.Prop.queryName)); - } - - // In 13.2, there was a change to use dataset names instead of labels for query references in reports, views, etc. - // We used to fix these up, but we no longer support that. For now, log an error to alert admins. - if (ctx.getArchiveVersion() != null && ctx.getArchiveVersion() < 13.11) - { - String schema = descriptor.getProperty(ReportDescriptor.Prop.schemaName); - StudyService svc = StudyService.get(); - Study study = svc != null ? svc.getStudy(ctx.getContainer()) : null; - if (study != null && schema != null && schema.equals("study")) - { - String queryName = descriptor.getProperty(ReportDescriptor.Prop.queryName); - Dataset dataset = study.getDatasetByLabel(queryName); - if (dataset != null && !dataset.getName().equals(dataset.getLabel())) - { - ctx.getLogger().error("Report \"{}\" could not be imported. Its queryName is \"{},\" which is a dataset label. Dataset labels are no longer supported; queryName should be set to the dataset name (\"{}\") instead.", xmlFileName, queryName, dataset.getName()); - return null; - } - } - } - - for (Report existingReport : getReports(ctx.getUser(), ctx.getContainer(), key)) - { - if (Strings.CI.equals(existingReport.getDescriptor().getReportName(), descriptor.getReportName())) - { - // Don't delete reports we just added. This can happen if the reportKey is not unique and - // we have two or more reports with the same name. This also works in the reload case since - // existing reports will not have the same report ids as the newly imported/created ones. - boolean shouldDelete = !ctx.isImportedReport(existingReport.getDescriptor()); - - if (shouldDelete) - deleteReport(new DefaultContainerUser(ctx.getContainer(), ctx.getUser()), existingReport); - } - } - - int rowId = _saveDbReport(ctx.getUser(), ctx.getContainer(), key, descriptor).getRowId(); - descriptor.setReportId(new DbReportIdentifier(rowId)); - - // re-load the report to get the updated property information (i.e container, etc.) - report = ReportService.get().getReport(ctx.getContainer(), rowId); - - // copy over the serialized report name - report.getDescriptor().setProperty(ReportDescriptor.Prop.serializedReportName, - descriptor.getProperty(ReportDescriptor.Prop.serializedReportName)); - - report.afterSave(ctx.getContainer(), ctx.getUser(), root); - - // remember that we imported this report so we don't try to delete it if - // we are importing another report with the same reportKey and name. - ctx.addImportedReport(report.getDescriptor()); - - // import any security role assignments - if (reportXml instanceof ReportDescriptorDocument doc) - { - ReportDescriptorType descriptorType = doc.getReportDescriptor(); - - if (descriptorType.isSetRoleAssignments()) - { - MutableSecurityPolicy policy = new MutableSecurityPolicy(report.getDescriptor()); - SecurityPolicyManager.importRoleAssignments(ctx, policy, descriptorType.getRoleAssignments()); - } - } - } - return report; - } - - @Override - public boolean reportNameExists(ViewContext context, String reportName, String key) - { - try - { - for (Report report : getReports(context.getUser(), context.getContainer(), key)) - { - if (Strings.CS.equals(reportName, report.getDescriptor().getReportName())) - return true; - } - return false; - } - catch (Exception e) - { - return false; - } - } - - @Override - public void maintenance(Logger log) - { - ScriptEngineReport.scheduledFileCleanup(log); - } - - private static class CategoryListener implements ViewCategoryListener - { - private final ReportServiceImpl _instance; - - private CategoryListener(ReportServiceImpl instance) - { - _instance = instance; - } - - @Override - public void categoryDeleted(User user, ViewCategory category) - { - for (Report report : getDatabaseReportsForCategory(category)) - { - Container c = ContainerManager.getForId(category.getContainerId()); - report.getDescriptor().setCategoryId(null); - - if (c != null) - _instance.saveReportEx(new DefaultContainerUser(c, user), report.getDescriptor().getReportKey(), report, true); - } - } - - @Override - public void categoryCreated(User user, ViewCategory category) - {} - - @Override - public void categoryUpdated(User user, ViewCategory category) - {} - - private Collection getDatabaseReportsForCategory(ViewCategory category) - { - if (category != null) - { - Integer categoryId = category.getRowId(); - return DatabaseReportCache.getReports(category.lookupContainer()) - .stream() - .filter(report -> categoryId.equals(report.getDescriptor().getCategoryId())) // These are all database reports, so we can use getCategoryId() - .collect(Collectors.toList()); - } - return Collections.emptyList(); - } - } - - private static class ReportServiceMaintenanceTask implements MaintenanceTask - { - @Override - public String getDescription() - { - return "Report Service Maintenance"; - } - - @Override - public String getName() - { - return "ReportService"; - } - - @Override - public void run(Logger log) - { - ReportService.get().maintenance(log); - } - } - - public static void registerUsageMetrics(String moduleName) - { - UsageMetricsService svc = UsageMetricsService.get(); - if (null != svc) - { - svc.registerUsageMetrics(moduleName, () -> { - // Iterate all the database reports once and produce two occurrence maps: all reports by type and just the charts by render type - MultiSet chartCountsByRenderType = new HashMultiSet<>(); - AtomicInteger genericChartWithTrendlineTypeCount = new AtomicInteger(); - AtomicInteger genericChartWithErrorBarsCount = new AtomicInteger(); - Map countsByType = ContainerManager.getAllChildren(ContainerManager.getRoot()).stream() - .flatMap(c -> ReportService.get().getReports(null, c).stream()) - .peek(report -> { - if (report instanceof GenericChartReport chart) - { - chartCountsByRenderType.add(chart.getRenderType()); - if (chart.getDescriptor() instanceof VisualizationReportDescriptor descriptor) - { - String configJson = descriptor.getJSON(); - if (configJson.contains("\"trendlineType\":") && !configJson.contains("\"trendlineType\":\"\"")) - genericChartWithTrendlineTypeCount.getAndIncrement(); - if (configJson.contains("\"errorBars\":\"SD\"") || configJson.contains("\"errorBars\":\"SEM\"")) - genericChartWithErrorBarsCount.getAndIncrement(); - } - } - }) - .collect(Collectors.groupingBy(Report::getType, Collectors.counting())); - - return Map.of( - "reportCountsByType", countsByType, - "genericChartCountsByRenderType", MultiSetUtils.getOccurrenceMap(chartCountsByRenderType), - "genericChartWithTrendlineTypeCount", genericChartWithTrendlineTypeCount, - "genericChartWithErrorBarsCount", genericChartWithErrorBarsCount - ); - }); - } - } -} +/* + * Copyright (c) 2008-2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.query.reports; + +import org.apache.commons.beanutils.BeanUtils; +import org.apache.commons.beanutils.ConvertUtils; +import org.apache.commons.collections4.MultiSet; +import org.apache.commons.collections4.multiset.HashMultiSet; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.apache.xmlbeans.XmlObject; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.admin.FolderExportContext; +import org.labkey.api.admin.FolderImportContext; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.collections.MultiSetUtils; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.CoreSchema; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.SqlExecutor; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.module.Module; +import org.labkey.api.moduleeditor.api.ModuleEditorService; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.SimpleValidationError; +import org.labkey.api.query.ValidationError; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reports.Report; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.model.ReportPropsManager; +import org.labkey.api.reports.model.ViewCategory; +import org.labkey.api.reports.model.ViewCategoryListener; +import org.labkey.api.reports.model.ViewCategoryManager; +import org.labkey.api.reports.report.AbstractReportIdentifier; +import org.labkey.api.reports.report.DbReportIdentifier; +import org.labkey.api.reports.report.ModuleJavaScriptReportDescriptor; +import org.labkey.api.reports.report.ModuleReportDescriptor; +import org.labkey.api.reports.report.ModuleReportIdentifier; +import org.labkey.api.reports.report.ReportDB; +import org.labkey.api.reports.report.ReportDescriptor; +import org.labkey.api.reports.report.ReportIdentifier; +import org.labkey.api.reports.report.ReportIdentifierConverter; +import org.labkey.api.reports.report.ScriptEngineReport; +import org.labkey.api.reports.report.ScriptReportDescriptor; +import org.labkey.api.reports.report.python.ModuleIpynbReportDescriptor; +import org.labkey.api.reports.report.r.ModuleRReportDescriptor; +import org.labkey.api.reports.report.r.RReportDescriptor; +import org.labkey.api.reports.report.view.ReportUtil; +import org.labkey.api.security.MutableSecurityPolicy; +import org.labkey.api.security.SecurityPolicyManager; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.study.Dataset; +import org.labkey.api.study.Study; +import org.labkey.api.study.StudyService; +import org.labkey.api.usageMetrics.UsageMetricsService; +import org.labkey.api.util.ContainerUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.SystemMaintenance; +import org.labkey.api.util.SystemMaintenance.MaintenanceTask; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.XmlBeansUtil; +import org.labkey.api.util.XmlValidationException; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.ViewContext; +import org.labkey.api.visualization.GenericChartReport; +import org.labkey.api.visualization.VisualizationReportDescriptor; +import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.DefaultContainerUser; +import org.labkey.api.writer.VirtualFile; +import org.labkey.query.xml.ReportDescriptorDocument; +import org.labkey.query.xml.ReportDescriptorType; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.labkey.api.reports.report.ScriptReportDescriptor.REPORT_METADATA_EXTENSION; + +public class ReportServiceImpl implements ContainerManager.ContainerListener, ReportService +{ + private static final Logger _log = LogHelper.getLogger(ReportService.class, "Errors and warnings with reports"); + private static final List _uiProviders = new CopyOnWriteArrayList<>(); + private static final Map _typeToProviderMap = new ConcurrentHashMap<>(); + private static final List _globalItemFilterTypes = new CopyOnWriteArrayList<>(); + + /** + * maps descriptor types to providers + */ + private final Map> _descriptors = new ConcurrentHashMap<>(); + + /** + * maps report types to implementations + */ + private final Map> _reports = new ConcurrentHashMap<>(); + + private final static ReportServiceImpl INSTANCE = new ReportServiceImpl(); + + public static ReportServiceImpl getInstance() + { + return INSTANCE; + } + + private ReportServiceImpl() + { + ContainerManager.addContainerListener(this); + ContainerManager.addSecurableResourceProvider((c, u) -> { + List ret = new ArrayList<>(); + for (Report report : ReportService.get().getReports(u, c)) + { + if (report.getDescriptor().hasPermission(u, AdminPermission.class)) + ret.add(report.getDescriptor()); + } + return ret; + }); + ConvertUtils.register(new ReportIdentifierConverter(), ReportIdentifier.class); + ReportQueryChangeListener listener = new ReportQueryChangeListener(); + QueryService.get().addQueryListener(listener); + QueryService.get().addCustomViewListener(listener); + SystemMaintenance.addTask(new ReportServiceMaintenanceTask()); + ViewCategoryManager.addCategoryListener(new CategoryListener(this)); + } + + @Override + public void registerDescriptor(ReportDescriptor descriptor) + { + if (descriptor == null) + throw new IllegalArgumentException("Invalid descriptor instance"); + + if (null != _descriptors.putIfAbsent(descriptor.getDescriptorType(), descriptor.getClass())) + _log.warn("Descriptor type : {} has previously been registered.", descriptor.getDescriptorType()); + } + + @Override + public ReportDescriptor createDescriptorInstance(String typeName) + { + if (typeName == null) + { + _log.error("createDescriptorInstance : typeName cannot be null"); + return null; + } + Class clazz = _descriptors.get(typeName); + + if (null == clazz) + return null; + + try + { + if (ReportDescriptor.class.isAssignableFrom(clazz)) + { + return (ReportDescriptor)clazz.getDeclaredConstructor().newInstance(); + } + + throw new IllegalArgumentException("The specified class: " + clazz.getName() + " is not an instance of ReportDescriptor"); + } + catch (Exception e) + { + throw new IllegalArgumentException("The specified class could not be created: " + clazz.getName()); + } + } + + @Override + @Nullable + public ReportDescriptor getModuleReportDescriptor(Module module, String path) + { + return ModuleReportCache.getModuleReportDescriptor(module, path); + } + + @Override + @NotNull + public List getModuleReportDescriptors(Module module, @Nullable String path) + { + return ModuleReportCache.getModuleReportDescriptors(module, path); + } + + @Override + public void registerReport(Report report) + { + if (report == null) + throw new IllegalArgumentException("Invalid report instance"); + + if (null != _reports.putIfAbsent(report.getType(), report.getClass())) + _log.warn("Report type : {} has previously been registered.", report.getType()); + } + + @Override + @Nullable + public Report createReportInstance(String typeName) + { + // ConcurrentHashMap doesn't support null keys, so do the extra check ourselves + if (typeName == null) + { + return null; + } + + Class clazz = _reports.get(typeName); + + if (null == clazz) + return null; + + try + { + if (Report.class.isAssignableFrom(clazz)) + { + Report report = (Report)clazz.getDeclaredConstructor().newInstance(); + report.getDescriptor().setReportType(typeName); + + return report; + } + + throw new IllegalArgumentException("The specified class: " + clazz.getName() + " is not an instance of Report"); + } + catch (Exception e) + { + throw new IllegalArgumentException("The specified class could not be created: " + clazz.getName()); + } + } + + @Override + @Nullable + public Report createReportInstance(ReportDescriptor descriptor) + { + Report report = createReportInstance(descriptor.getReportType()); + report.setDescriptor(descriptor); + return report; + } + + private static TableInfo getTable() + { + return CoreSchema.getInstance().getTableInfoReport(); + } + + @Override + public void containerDeleted(Container c, User user) + { + ContainerUtil.purgeTable(getTable(), c, "ContainerId"); + DatabaseReportCache.uncache(c); + } + + @Override + public Report createFromQueryString(String queryString) + { + for (Pair param : PageFlowUtil.fromQueryString(queryString)) + { + if (ReportDescriptor.Prop.reportType.toString().equals(param.getKey())) + { + if (param.getValue() != null) + { + Report report = createReportInstance(param.getValue()); + report.getDescriptor().initFromQueryString(queryString); + return report; + } + } + } + return null; + } + + @Override + public void deleteReport(ContainerUser context, Report report) + { + //ensure that descriptor id is a DbReportIdentifier + DbReportIdentifier reportId; + + if (report.getDescriptor().getReportId() instanceof DbReportIdentifier) + reportId = (DbReportIdentifier)(report.getDescriptor().getReportId()); + else + throw new RuntimeException("Can't delete a report that is not stored in the database!"); + + DbScope scope = getTable().getSchema().getScope(); + + try (DbScope.Transaction tx = scope.ensureTransaction()) + { + report.beforeDelete(context); + + final ReportDescriptor descriptor = report.getDescriptor(); + _deleteReport(context.getContainer(), context.getUser(), reportId.getRowId(), descriptor); + SecurityPolicyManager.deletePolicy(descriptor); + tx.commit(); + } + } + + private void _deleteReport(Container c, User u, int reportId, ReportDescriptor descriptor) + { + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("ContainerId"), c.getId()); + filter.addCondition(FieldKey.fromParts("RowId"), reportId); + Table.delete(getTable(), filter); + DatabaseReportCache.uncache(c); + + ReportAuditProvider.ReportAuditEvent event = new ReportAuditProvider.ReportAuditEvent(reportId, descriptor, c, "Report deleted"); + AuditLogService.get().addEvent(u, event); + } + + @Override + public ReportIdentifier saveReportEx(ContainerUser context, String key, Report report, boolean skipValidation) + { + ReportIdentifier id = report.getDescriptor().getReportId(); + if (null == id || id instanceof DbReportIdentifier) + { + if (report.getDescriptor().isModuleBased()) + throw new IllegalStateException(); + int rowid = _saveDbReport(context, key, report, skipValidation).getRowId(); + return new DbReportIdentifier(rowid); + } + + ReportDescriptor descriptor = report.getDescriptor(); + + // NOTE there are module reports other than R + if (id instanceof ModuleReportIdentifier && + (descriptor instanceof ModuleRReportDescriptor || descriptor instanceof ModuleJavaScriptReportDescriptor || descriptor instanceof ModuleIpynbReportDescriptor)) + { + return _saveModuleReport(context, key, report, skipValidation); + } + else + { + throw new RuntimeException("Can't save this kind of module report yet."); + } + } + + @Override + public void validateReportPermissions(ContainerUser context, Report report) + { + List errors = new ArrayList<>(); + + tryValidateReportPermissions(context, report, errors); + + if (!errors.isEmpty()) + { + StringBuilder sb = new StringBuilder(); + for (ValidationError error : errors) + { + if (!sb.isEmpty()) + sb.append("\n"); + + sb.append(error.getMessage()); + } + + throw new UnauthorizedException(sb.toString()); + } + } + + @Override + public boolean tryValidateReportPermissions(ContainerUser context, Report report, List errors) + { + final ReportDescriptor descriptor = report.getDescriptor(); + + if (descriptor.isNew()) + { + if (descriptor.isShared()) + report.canShare(context.getUser(), context.getContainer(), errors); + } + else + { + Report stored = getStoredReport(descriptor.getReportId()); + if (null != stored && !context.getContainer().getId().equals(stored.getContainerId())) + { + errors.add(new SimpleValidationError("A report can only be saved from the folder that it belongs to.")); + return false; + } + + Report toCheck = null != stored ? stored : report; + + if (toCheck.canEdit(context.getUser(), context.getContainer(), errors)) + { + if (descriptor.isShared()) + toCheck.canShare(context.getUser(), context.getContainer(), errors); + } + } + + return errors.isEmpty(); + } + + private ReportDB _saveDbReport(ContainerUser context, String key, Report report, boolean skipValidation) + { + DbScope scope = getTable().getSchema().getScope(); + ReportDescriptor descriptor; + ReportDB r; + try (DbScope.Transaction tx = scope.ensureTransaction()) + { + report.getDescriptor().setContainer(context.getContainer().getId()); + report.beforeSave(context); + + descriptor = report.getDescriptor(); + + // last chance to validate permissions, this should be done in the controller actions, so + // just throw an exception if validation fails + if (!skipValidation) + validateReportPermissions(context, report); + + r = _saveDbReport(context.getUser(), context.getContainer(), key, descriptor); + tx.commit(); + } + _saveReportProperties(context.getContainer(), r.getEntityId(), descriptor); + return r; + } + + private ReportDB _saveDbReport(User user, Container c, String key, ReportDescriptor descriptor) + { + ReportDB reportDB = new ReportDB(c, key, descriptor); + + //ensure that descriptor id is a DbReportIdentifier + DbReportIdentifier reportId; + if (null == descriptor.getReportId() || descriptor.getReportId() instanceof DbReportIdentifier) + { + reportId = (DbReportIdentifier)(descriptor.getReportId()); + if (reportId != null) + reportDB.setRowId(reportId.getRowId()); + } + else + throw new RuntimeException("Can't save a report that is not stored in the database!"); + + // A descriptor's reportId can come straight from client input, so it may name a row in any container. + ReportDB existing = null != reportId ? getReportDB(reportId.getRowId()) : null; + if (null != existing && !c.getId().equals(existing.getContainerId())) + throw new UnauthorizedException("A report can only be saved from the folder that it belongs to."); + + boolean reportExists = null != existing; + if (reportExists) + reportDB = Table.update(user, getTable(), reportDB, reportId.getRowId(), + new SimpleFilter(FieldKey.fromParts("ContainerId"), c.getId()), Level.WARN); + else + reportDB = Table.insert(user, getTable(), reportDB); + + DatabaseReportCache.uncache(c); + + ReportAuditProvider.ReportAuditEvent event = new ReportAuditProvider.ReportAuditEvent(reportDB, descriptor, c, reportExists ? "Report updated" : "Report created"); + AuditLogService.get().addEvent(user, event); + + return reportDB; + } + + private void _saveReportProperties(Container c, String entityId, ReportDescriptor descriptor) + { + // consider: make this more generic instead of picking out these specific properties + try { + if (null != descriptor.getAuthorAsObject()) + ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.author.name(), descriptor.getAuthorAsObject()); + if (null != descriptor.getStatus()) + ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.status.name(), descriptor.getStatus()); + if (null != descriptor.getRefreshDateAsObject()) + ReportPropsManager.get().setPropertyValue(entityId, c, ReportDescriptor.Prop.refreshDate.name(), descriptor.getRefreshDateAsObject()); + } + catch (ValidationException e) { + throw new RuntimeException(e); + } + } + + + private ReportIdentifier _saveModuleReport(ContainerUser context, String key, Report report, boolean skipValidation) + { + if (!(report.getDescriptor() instanceof ModuleReportDescriptor descriptor) || !(report.getDescriptor().getReportId() instanceof ModuleReportIdentifier)) + throw new IllegalStateException("This should be a module report!"); + + User user = context.getUser(); + Container c = context.getContainer(); + + report.beforeSave(context); + + // last chance to validate permissions, this should be done in the controller actions, so + // just throw an exception if validation fails + if (!skipValidation) + validateReportPermissions(context, report); + + File scriptFile = ModuleEditorService.get().getFileForModuleResource(descriptor.getModule(), descriptor.getSourceFile().getPath()); + if (null == scriptFile || !scriptFile.canWrite()) + throw new RuntimeException("This module resource can not be edited"); // shouldn't happen + File xmlFile; + if (null != descriptor.getMetaDataFile()) + { + xmlFile = ModuleEditorService.get().getFileForModuleResource(descriptor.getModule(), descriptor.getMetaDataFile().getPath()); + } + else + { + xmlFile = new File(scriptFile.getParentFile(), descriptor.getReportName() + REPORT_METADATA_EXTENSION); + } + if (!(xmlFile.exists() ? xmlFile.isFile() && xmlFile.canWrite() : xmlFile.getParentFile().canWrite())) + throw new RuntimeException("This module resource can not be edited"); // shouldn't happen + + try + { + String script = report.getDescriptor().getProperty(ScriptReportDescriptor.Prop.script); + String reportXml; + // we want this to act like folder export (don't persist script property), not like database save, so use getDescriptorDocument(FolderExportContext) + FolderExportContext ex = new FolderExportContext(user, c, null, null, null); + ReportDescriptorDocument reportDoc = report.getDescriptor().getDescriptorDocument(ex); + try (StringWriter writer = new StringWriter()) + { + reportDoc.save(writer, XmlBeansUtil.getDefaultSaveOptions()); + reportXml = writer.toString(); + } + FileUtils.write(scriptFile, script, StringUtilsLabKey.DEFAULT_CHARSET); + FileUtils.write(xmlFile, reportXml, StringUtilsLabKey.DEFAULT_CHARSET); + } + catch (IOException x) + { + throw UnexpectedException.wrap(x); + } + + // TODO save report.xml + // _saveReportProperties(context.getContainer(), r.getEntityId(), descriptor); + + // avoid having a race between reselecting the report, and the file watcher + ModuleReportCache.uncache(descriptor.getModule()); + return descriptor.getReportId(); + } + + + public @Nullable Report _getInstance(ReportDB r) + { + if (r != null) + { + try + { + ReportDescriptor descriptor = ReportDescriptor.createFromXML(r.getDescriptorXML()); + + if (descriptor != null) + { + BeanUtils.copyProperties(descriptor, r); + descriptor.setReportId(new DbReportIdentifier(r.getRowId())); + descriptor.setOwner(r.getReportOwner()); + descriptor.setDisplayOrder(r.getDisplayOrder()); + + if (r.getCategoryId() != null) + descriptor.setCategoryId(r.getCategoryId()); + + descriptor.initProperties(); + + String type = descriptor.getReportType(); + Report report = createReportInstance(type); + if (report != null) + report.setDescriptor(descriptor); + return report; + } + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + return null; + } + + @Override + public void setReportDisplayOrder(ContainerUser context, Report report, int displayOrder) + { + ReportIdentifier reportIdentifier = report.getDescriptor().getReportId(); + if (reportIdentifier != null && reportIdentifier.getRowId() != 0) + { + TableInfo table = getTable(); + SQLFragment sql = new SQLFragment("UPDATE ").append(table, ""); + sql.append(" SET DisplayOrder = ? WHERE RowId = ?"); + sql.addAll(displayOrder, reportIdentifier.getRowId()); + + new SqlExecutor(table.getSchema()).execute(sql); + + DatabaseReportCache.uncache(context.getContainer()); + } + } + + @Override + public Report getReportByEntityId(Container c, String entityId) + { + if (StringUtils.isBlank(entityId)) + return null; + if (GUID.isGUID(entityId)) + { + return DatabaseReportCache.getReportByEntityId(c, entityId); + } + else + { + // TODO hack: see ModuleRReportDescriptor.getEntityId() + ReportIdentifier id = getReportIdentifier(PageFlowUtil.decode(entityId), null, c); + if (null == id) + return null; + return id.getReport(new DefaultContainerUser(c, null)); + } + } + + @Override + public Report getReport(Container c, int rowId) + { + Report report = DatabaseReportCache.getReport(c, rowId); + + if (null != report) + return report; + + while (!c.isRoot()) + { + c = c.getParent(); + report = DatabaseReportCache.getReport(c, rowId); + + if (null != report) + { + if (report.getDescriptor().isInheritable()) + return report; + else + return null; + } + } + + // Look for this report in the shared project + return (!ContainerManager.getSharedContainer().equals(c)) ? DatabaseReportCache.getReport(ContainerManager.getSharedContainer(), rowId) : null; + } + + @Override + public ReportIdentifier getReportIdentifier(String reportId, @Nullable User user, @Nullable Container container) + { + return AbstractReportIdentifier.fromString(reportId, user, container); + } + + @Override + public Collection getReports(@Nullable User user, @NotNull Container c) + { + List reportsList = new ArrayList<>(DatabaseReportCache.getReports(c)); + return getSortedReadableReports(reportsList, user); + } + + @Override + public Collection getReports(@Nullable User user, @NotNull Container c, @Nullable String key) + { + List moduleReportDescriptors = new ArrayList<>(); + + for (Module module : c.getActiveModules()) + { + moduleReportDescriptors.addAll(getModuleReportDescriptors(module, key)); + } + + List reports = new ArrayList<>(); + + for (ReportDescriptor descriptor : moduleReportDescriptors) + { + String type = descriptor.getReportType(); + Report report = createReportInstance(type); + + if (report != null) + { + report.setDescriptor(descriptor); + reports.add(report); + } + } + + if (key == null) + { + reports.addAll(DatabaseReportCache.getReports(c)); + } + else + { + reports.addAll(DatabaseReportCache.getReportsByReportKey(c, key)); + } + + return getSortedReadableReports(reports, user); + } + + @Override + @Deprecated + public Collection getInheritableReports(User user, Container c, @Nullable String reportKey) + { + Collection inheritable = DatabaseReportCache.getInheritableReports(c); + + // If reportKey is specified then grab just those from the inheritable reports + if (null != reportKey) + { + inheritable = inheritable.stream() + .filter(report -> reportKey.equals(report.getDescriptor().getReportKey())) + .toList(); + } + + List reportsList = new ArrayList<>(inheritable); + + return getSortedReadableReports(reportsList, user); + } + + private static Collection getSortedReadableReports(List reports, @Nullable User user) + { + List readableReports; + + if (null == user) + { + readableReports = reports; + } + else + { + readableReports = reports + .stream() + .filter(report -> report.getDescriptor().isModuleBased() || report.hasPermission(user, report.getDescriptor().getResourceContainer(), ReadPermission.class)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + // must re-sort to allow file-based reports to show in proper positions + // NOTE: currently, the only way for file-based reports to appear in the middle of a category is to share a + // displayOrder number with a report already in the cache (all indices in a range are used when + // persisting); therefore the file-based report's order can never be fully guaranteed + readableReports.sort(Comparator.comparingInt(r -> r.getDescriptor().getDisplayOrder())); + + return readableReports; + } + + @Override + @Nullable + public Report getReport(ReportDB reportDB) + { + return _getInstance(reportDB); + } + + @Override + public void addUIProvider(UIProvider provider) + { + _uiProviders.add(provider); + } + + @Override + public List getUIProviders() + { + return Collections.unmodifiableList(_uiProviders); + } + + @Override + public void addGlobalItemFilterType(String type) + { + _globalItemFilterTypes.add(type); + } + + @Override + public List getGlobalItemFilterTypes() + { + return Collections.unmodifiableList(_globalItemFilterTypes); + } + + @Override + public @NotNull String getIconPath(Report report) + { + if (report != null) + { + String reportType = report.getType(); + + UIProvider claimingProvider = _typeToProviderMap.get(reportType); + + if (null != claimingProvider) + { + String iconPath = claimingProvider.getIconPath(report); + + if (null == iconPath) + throw new IllegalStateException(reportType + " is claimed by " + claimingProvider + " but iconPath is null"); + + return iconPath; + } + + for (UIProvider provider : _uiProviders) + { + String iconPath = provider.getIconPath(report); + + if (iconPath != null) + { + _typeToProviderMap.put(reportType, provider); + return iconPath; + } + } + } + + // No UIProvider claimed this report type... so fall-back on blank image + return "/_.gif"; + } + + @Override + public @Nullable String getIconCls(Report report) + { + if (report != null) + { + String reportType = report.getType(); + + UIProvider claimingProvider = _typeToProviderMap.get(reportType); + + if (null != claimingProvider) + { + return claimingProvider.getIconCls(report); + } + + for (UIProvider provider : _uiProviders) + { + String iconClass = provider.getIconCls(report); + + if (iconClass != null) + { + _typeToProviderMap.put(reportType, provider); + return iconClass; + } + } + } + + // No report provider claimed this, so don't return an icon (we should always have an image icon to fall back on anyway) + return null; + } + + /** Unscoped by container on purpose: callers need the row's own container to decide whether they may touch it. */ + private @Nullable ReportDB getReportDB(int reportId) + { + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("RowId"), reportId); + return new TableSelector(getTable(), filter, null).getObject(ReportDB.class); + } + + /** The persisted report a descriptor's reportId names, or null if it names no database row. */ + private @Nullable Report getStoredReport(@Nullable ReportIdentifier reportId) + { + return reportId instanceof DbReportIdentifier dbReportId ? _getInstance(getReportDB(dbReportId.getRowId())) : null; + } + + @Nullable + private Report _deserialize(Container container, User user, XmlObject reportXml) throws IOException, XmlValidationException + { + ReportDescriptor descriptor = ReportDescriptor.createFromXmlObject(container, user, reportXml); + + if (descriptor != null) + { + //descriptor.setReportId(new DbReportIdentifier(r.getRowId())); + //descriptor.setOwner(r.getReportOwner()); + + String type = descriptor.getReportType(); + Report report = createReportInstance(type); + + if (report != null) + { + report.setDescriptor(descriptor); + report.afterImport(container, user); + } + + return report; + } + + return null; + } + + @Nullable + private Report deserialize(Container container, User user, XmlObject reportXml, VirtualFile root, String xmlFileName) throws IOException, XmlValidationException + { + if (null != reportXml) + { + Report report = _deserialize(container, user, reportXml); + + // reset any report identifier, we want to treat an imported report as a new + // report instance + if (report != null) + { + ReportDescriptor descriptor = report.getDescriptor(); + descriptor.setReportId(new DbReportIdentifier(-1)); + + // if this is an R report look for report source in separate file + if (descriptor instanceof RReportDescriptor && xmlFileName.toLowerCase().endsWith(".report.xml")) + { + String baseName = xmlFileName.substring(0, xmlFileName.length() - ".report.xml".length()); + InputStream is = null; + try + { + is = root.getInputStream(baseName + ".R"); + if (null == is) + is = root.getInputStream(baseName + ".r"); + if (null != is) + { + String script = IOUtils.toString(is, StringUtilsLabKey.DEFAULT_CHARSET); + if (!StringUtils.isBlank(script)) + descriptor.setProperty(ScriptReportDescriptor.Prop.script, script); + } + } + finally + { + // not using try with resources because of trying to open .R and .r + IOUtils.closeQuietly(is); + } + } + } + + return report; + } + + throw new IllegalArgumentException("Report XML file does not exist."); + } + + @Override @Nullable + public Report importReport(FolderImportContext ctx, XmlObject reportXml, VirtualFile root, String xmlFileName) throws IOException, XmlValidationException + { + Report report = deserialize(ctx.getContainer(), ctx.getUser(), reportXml, root, xmlFileName); + if (report != null) + { + ReportDescriptor descriptor = report.getDescriptor(); + String key = descriptor.getReportKey(); + if (StringUtils.isBlank(key)) + { + // use the default key used by query views + key = ReportUtil.getReportKey(descriptor.getProperty(ReportDescriptor.Prop.schemaName), descriptor.getProperty(ReportDescriptor.Prop.queryName)); + } + + // In 13.2, there was a change to use dataset names instead of labels for query references in reports, views, etc. + // We used to fix these up, but we no longer support that. For now, log an error to alert admins. + if (ctx.getArchiveVersion() != null && ctx.getArchiveVersion() < 13.11) + { + String schema = descriptor.getProperty(ReportDescriptor.Prop.schemaName); + StudyService svc = StudyService.get(); + Study study = svc != null ? svc.getStudy(ctx.getContainer()) : null; + if (study != null && schema != null && schema.equals("study")) + { + String queryName = descriptor.getProperty(ReportDescriptor.Prop.queryName); + Dataset dataset = study.getDatasetByLabel(queryName); + if (dataset != null && !dataset.getName().equals(dataset.getLabel())) + { + ctx.getLogger().error("Report \"{}\" could not be imported. Its queryName is \"{},\" which is a dataset label. Dataset labels are no longer supported; queryName should be set to the dataset name (\"{}\") instead.", xmlFileName, queryName, dataset.getName()); + return null; + } + } + } + + for (Report existingReport : getReports(ctx.getUser(), ctx.getContainer(), key)) + { + if (Strings.CI.equals(existingReport.getDescriptor().getReportName(), descriptor.getReportName())) + { + // Don't delete reports we just added. This can happen if the reportKey is not unique and + // we have two or more reports with the same name. This also works in the reload case since + // existing reports will not have the same report ids as the newly imported/created ones. + boolean shouldDelete = !ctx.isImportedReport(existingReport.getDescriptor()); + + if (shouldDelete) + deleteReport(new DefaultContainerUser(ctx.getContainer(), ctx.getUser()), existingReport); + } + } + + int rowId = _saveDbReport(ctx.getUser(), ctx.getContainer(), key, descriptor).getRowId(); + descriptor.setReportId(new DbReportIdentifier(rowId)); + + // re-load the report to get the updated property information (i.e container, etc.) + report = ReportService.get().getReport(ctx.getContainer(), rowId); + + // copy over the serialized report name + report.getDescriptor().setProperty(ReportDescriptor.Prop.serializedReportName, + descriptor.getProperty(ReportDescriptor.Prop.serializedReportName)); + + report.afterSave(ctx.getContainer(), ctx.getUser(), root); + + // remember that we imported this report so we don't try to delete it if + // we are importing another report with the same reportKey and name. + ctx.addImportedReport(report.getDescriptor()); + + // import any security role assignments + if (reportXml instanceof ReportDescriptorDocument doc) + { + ReportDescriptorType descriptorType = doc.getReportDescriptor(); + + if (descriptorType.isSetRoleAssignments()) + { + MutableSecurityPolicy policy = new MutableSecurityPolicy(report.getDescriptor()); + SecurityPolicyManager.importRoleAssignments(ctx, policy, descriptorType.getRoleAssignments()); + } + } + } + return report; + } + + @Override + public boolean reportNameExists(ViewContext context, String reportName, String key) + { + try + { + for (Report report : getReports(context.getUser(), context.getContainer(), key)) + { + if (Strings.CS.equals(reportName, report.getDescriptor().getReportName())) + return true; + } + return false; + } + catch (Exception e) + { + return false; + } + } + + @Override + public void maintenance(Logger log) + { + ScriptEngineReport.scheduledFileCleanup(log); + } + + private static class CategoryListener implements ViewCategoryListener + { + private final ReportServiceImpl _instance; + + private CategoryListener(ReportServiceImpl instance) + { + _instance = instance; + } + + @Override + public void categoryDeleted(User user, ViewCategory category) + { + for (Report report : getDatabaseReportsForCategory(category)) + { + Container c = ContainerManager.getForId(category.getContainerId()); + report.getDescriptor().setCategoryId(null); + + if (c != null) + _instance.saveReportEx(new DefaultContainerUser(c, user), report.getDescriptor().getReportKey(), report, true); + } + } + + @Override + public void categoryCreated(User user, ViewCategory category) + {} + + @Override + public void categoryUpdated(User user, ViewCategory category) + {} + + private Collection getDatabaseReportsForCategory(ViewCategory category) + { + if (category != null) + { + Integer categoryId = category.getRowId(); + return DatabaseReportCache.getReports(category.lookupContainer()) + .stream() + .filter(report -> categoryId.equals(report.getDescriptor().getCategoryId())) // These are all database reports, so we can use getCategoryId() + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + } + + private static class ReportServiceMaintenanceTask implements MaintenanceTask + { + @Override + public String getDescription() + { + return "Report Service Maintenance"; + } + + @Override + public String getName() + { + return "ReportService"; + } + + @Override + public void run(Logger log) + { + ReportService.get().maintenance(log); + } + } + + public static void registerUsageMetrics(String moduleName) + { + UsageMetricsService svc = UsageMetricsService.get(); + if (null != svc) + { + svc.registerUsageMetrics(moduleName, () -> { + // Iterate all the database reports once and produce two occurrence maps: all reports by type and just the charts by render type + MultiSet chartCountsByRenderType = new HashMultiSet<>(); + AtomicInteger genericChartWithTrendlineTypeCount = new AtomicInteger(); + AtomicInteger genericChartWithErrorBarsCount = new AtomicInteger(); + Map countsByType = ContainerManager.getAllChildren(ContainerManager.getRoot()).stream() + .flatMap(c -> ReportService.get().getReports(null, c).stream()) + .peek(report -> { + if (report instanceof GenericChartReport chart) + { + chartCountsByRenderType.add(chart.getRenderType()); + if (chart.getDescriptor() instanceof VisualizationReportDescriptor descriptor) + { + String configJson = descriptor.getJSON(); + if (configJson.contains("\"trendlineType\":") && !configJson.contains("\"trendlineType\":\"\"")) + genericChartWithTrendlineTypeCount.getAndIncrement(); + if (configJson.contains("\"errorBars\":\"SD\"") || configJson.contains("\"errorBars\":\"SEM\"")) + genericChartWithErrorBarsCount.getAndIncrement(); + } + } + }) + .collect(Collectors.groupingBy(Report::getType, Collectors.counting())); + + return Map.of( + "reportCountsByType", countsByType, + "genericChartCountsByRenderType", MultiSetUtils.getOccurrenceMap(chartCountsByRenderType), + "genericChartWithTrendlineTypeCount", genericChartWithTrendlineTypeCount, + "genericChartWithErrorBarsCount", genericChartWithErrorBarsCount + ); + }); + } + } +}