Skip to content

Commit cb2b140

Browse files
authored
Delete orphaned attachments (#7513)
1 parent f0c052a commit cb2b140

6 files changed

Lines changed: 129 additions & 22 deletions

File tree

api/src/org/labkey/api/attachments/AttachmentService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ static AttachmentService get()
140140

141141
HttpView<?> getFindAttachmentParentsView();
142142

143-
void detectOrphans();
143+
void logOrphanedAttachments();
144+
145+
void deleteOrphanedAttachments();
144146

145147
class DuplicateFilenameException extends IOException implements SkipMothershipLogging
146148
{

api/src/org/labkey/api/data/ContainerManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1944,7 +1944,7 @@ private static boolean delete(final Container c, User user, @Nullable String com
19441944
setContainerTabDeleted(c.getParent(), c.getName(), c.getParent().getFolderType().getName());
19451945
}
19461946

1947-
AttachmentService.get().detectOrphans();
1947+
AttachmentService.get().logOrphanedAttachments();
19481948

19491949
fireDeleteContainer(c, user);
19501950

core/module.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Name: Core
22
ModuleClass: org.labkey.core.CoreModule
3-
SchemaVersion: 26.002
3+
SchemaVersion: 26.003
44
Label: Administration and Essential Services
55
Description: The Core module provides central services such as login, \
66
security, administration, folder management, user management, \

core/src/org/labkey/core/CoreUpgradeCode.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public static void migrateAllowedExternalConnectionHosts(ModuleContext context)
7171
if (context.isNewInstall())
7272
return;
7373

74+
// TODO: Remove getExternalSourceHosts() method when this upgrade code is deleted
7475
List<String> hosts = AppProps.getInstance().getExternalSourceHosts();
7576
List<AllowedHost> allowedHosts = hosts.stream()
7677
.map(host -> new AllowedHost(Directive.Connection, host))
@@ -106,4 +107,23 @@ public static void populateAttachmentParentTypeColumn(ModuleContext context)
106107
new SqlExecutor(CoreSchema.getInstance().getSchema()).execute(updateSql);
107108
}
108109
}
109-
}
110+
111+
/**
112+
* This is not invoked from any script yet. We want to make sure our orphaned attachment detection is perfect
113+
* before blanket deleting them.
114+
*/
115+
@DeferredUpgrade // Need to execute this after AttachmentTypes are registered
116+
@SuppressWarnings("unused")
117+
public static void deleteOrphanedAttachments(ModuleContext context)
118+
{
119+
if (context.isNewInstall())
120+
return;
121+
122+
AttachmentService svc = AttachmentService.get();
123+
124+
if (svc != null)
125+
{
126+
svc.deleteOrphanedAttachments();
127+
}
128+
}
129+
}

core/src/org/labkey/core/attachment/AttachmentServiceImpl.java

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.apache.commons.io.IOUtils;
2222
import org.apache.commons.lang3.StringUtils;
2323
import org.apache.commons.lang3.Strings;
24+
import org.apache.commons.lang3.mutable.MutableInt;
2425
import org.apache.logging.log4j.Logger;
2526
import org.jetbrains.annotations.NotNull;
2627
import org.jetbrains.annotations.Nullable;
@@ -45,6 +46,7 @@
4546
import org.labkey.api.data.ColumnRenderProperties;
4647
import org.labkey.api.data.CompareType;
4748
import org.labkey.api.data.Container;
49+
import org.labkey.api.data.ContainerFilter;
4850
import org.labkey.api.data.ContainerFilter.AllFolders;
4951
import org.labkey.api.data.ContainerManager;
5052
import org.labkey.api.data.CoreSchema;
@@ -113,6 +115,7 @@
113115
import org.labkey.api.webdav.WebdavResolver;
114116
import org.labkey.api.webdav.WebdavResource;
115117
import org.labkey.core.query.AttachmentAuditProvider;
118+
import org.labkey.core.query.AttachmentAuditProvider.AttachmentAuditEvent;
116119
import org.labkey.core.query.CoreQuerySchema;
117120
import org.springframework.http.ContentDisposition;
118121
import org.springframework.mock.web.MockMultipartFile;
@@ -203,7 +206,7 @@ public void addAuditEvent(User user, AttachmentParent parent, String filename, S
203206
if (parent != null)
204207
{
205208
Container c = ContainerManager.getForId(parent.getContainerId());
206-
AttachmentAuditProvider.AttachmentAuditEvent attachmentEvent = new AttachmentAuditProvider.AttachmentAuditEvent(c == null ? ContainerManager.getRoot() : c, comment);
209+
AttachmentAuditEvent attachmentEvent = new AttachmentAuditEvent(c == null ? ContainerManager.getRoot() : c, comment);
207210

208211
attachmentEvent.setAttachmentParentEntityId(parent.getEntityId());
209212
attachmentEvent.setParentType(parent.getAttachmentParentType().getUniqueName());
@@ -1098,7 +1101,7 @@ public int available()
10981101
private record Orphan(String documentName, String parentType){}
10991102

11001103
@Override
1101-
public void detectOrphans()
1104+
public void logOrphanedAttachments()
11021105
{
11031106
// Log orphaned attachments in this server, but in dev mode only, since this is for our testing. Also, we
11041107
// don't yet offer a way to delete orphaned attachments via the UI, so it's not helpful to inform admins.
@@ -1135,6 +1138,69 @@ public void detectOrphans()
11351138
}
11361139
}
11371140

1141+
record OrphanedAttachment(String container, String parent, String parentType, String documentName)
1142+
{
1143+
AttachmentParent getAttachmentParent()
1144+
{
1145+
return new AttachmentParent()
1146+
{
1147+
@Override
1148+
public String getEntityId()
1149+
{
1150+
return parent;
1151+
}
1152+
1153+
@Override
1154+
public String getContainerId()
1155+
{
1156+
return container;
1157+
}
1158+
1159+
@Override
1160+
public @NotNull AttachmentParentType getAttachmentParentType()
1161+
{
1162+
// Attempt to resolve the parent type. This will get written to the audit log.
1163+
AttachmentParentType type = ATTACHMENT_TYPE_MAP.get(parentType());
1164+
return type != null ? type : AttachmentParentType.UNKNOWN;
1165+
}
1166+
};
1167+
}
1168+
}
1169+
1170+
@Override
1171+
public void deleteOrphanedAttachments()
1172+
{
1173+
// TroubleShooterRole provides ability to read the Documents table. deleteAttachments() does not check perms.
1174+
User user = ElevatedUser.getElevatedUser(User.getSearchUser(), TroubleshooterRole.class);
1175+
UserSchema core = DefaultSchema.get(user, ContainerManager.getRoot()).getUserSchema(CoreQuerySchema.NAME);
1176+
if (core != null)
1177+
{
1178+
// Use "unsafe everything" container filter because it's possible that orphaned attachments have a container
1179+
// that no longer exists.
1180+
TableInfo documents = core.getTable(CoreQuerySchema.DOCUMENTS_TABLE_NAME, ContainerFilter.getUnsafeEverythingFilter());
1181+
if (null != documents)
1182+
{
1183+
SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("Orphaned"), true);
1184+
MutableInt count = new MutableInt(0);
1185+
new TableSelector(documents, new CsvSet("Container, Parent, ParentType, DocumentName"), filter, null).forEach(OrphanedAttachment.class, orphan -> {
1186+
LOG.info("Deleting orphaned attachment: {}", orphan);
1187+
try
1188+
{
1189+
deleteAttachment(orphan.getAttachmentParent(), orphan.documentName(), user);
1190+
count.increment();
1191+
}
1192+
catch (Exception e)
1193+
{
1194+
LOG.error("Exception while deleting orphaned attachment: {}", orphan, e);
1195+
}
1196+
});
1197+
AttachmentAuditEvent event = new AttachmentAuditEvent(ContainerManager.getRoot(), "Deleted " + StringUtilsLabKey.pluralize(count.intValue(), "orphaned attachment"));
1198+
event.setAttachment("All orphaned attachments");
1199+
AuditLogService.get().addEvent(user, event);
1200+
}
1201+
}
1202+
}
1203+
11381204
private CoreSchema coreTables()
11391205
{
11401206
return CoreSchema.getInstance();

experiment/src/org/labkey/experiment/api/ExpDataClassType.java

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package org.labkey.experiment.api;
1717

18+
import org.apache.logging.log4j.Logger;
1819
import org.jetbrains.annotations.NotNull;
1920
import org.labkey.api.attachments.AttachmentParentType;
2021
import org.labkey.api.data.Container;
@@ -28,15 +29,19 @@
2829
import org.labkey.api.exp.api.ExperimentService;
2930
import org.labkey.api.exp.property.Domain;
3031
import org.labkey.api.exp.property.PropertyService;
32+
import org.labkey.api.exp.query.DataClassUserSchema;
33+
import org.labkey.api.security.User;
3134
import org.labkey.api.util.PageFlowUtil;
3235
import org.labkey.api.util.Pair;
36+
import org.labkey.api.util.logging.LogHelper;
3337

3438
import java.util.LinkedList;
3539
import java.util.List;
3640

3741
public class ExpDataClassType implements AttachmentParentType
3842
{
3943
private static final AttachmentParentType INSTANCE = new ExpDataClassType();
44+
private static final Logger LOG = LogHelper.getLogger(ExpDataClassType.class, "Issues selecting entityIds");
4045

4146
private ExpDataClassType()
4247
{
@@ -74,22 +79,36 @@ public static AttachmentParentType get()
7479
String lsid = rs.getString("LSID");
7580
Domain domain = PropertyService.get().getDomain(c, lsid);
7681

77-
// Add a select for the ObjectIds in this ExpDataClass if the domain includes an attachment column. ExpDataClass attachments
78-
// use the LSID's ObjectId as the attachment parent EntityId, so we need to use a SQL expression to extract it.
79-
if (null != domain && domain.getProperties().stream().anyMatch(p -> p.getPropertyType() == PropertyType.ATTACHMENT))
80-
selectStatements.add(
81-
new SQLFragment("\n SELECT ")
82-
.append(expressionToExtractObjectId)
83-
.append(" AS EntityId, ")
84-
.append(dialect.concatenate(
85-
new SQLFragment("?", domain.getName()),
86-
new SQLFragment("':'"),
87-
new SQLFragment("Name")
88-
))
89-
.append(" AS Description FROM expdataclass.")
90-
.append(domain.getStorageTableName())
91-
.append(" WHERE ").append(where)
92-
);
82+
if (null != domain)
83+
{
84+
// Enumerate columns on the data class TableInfo since it includes the vocabulary domain columns.
85+
// For example, Compound has a built-in Structure2D attachment column supplied by a vocabulary domain.
86+
TableInfo dataClassTable = new DataClassUserSchema(c, User.getSearchUser()).getTable(domain.getName());
87+
88+
if (dataClassTable == null)
89+
{
90+
LOG.warn("DataClass table not found for {}", domain.getName());
91+
}
92+
else if (dataClassTable.getColumns().stream().anyMatch(col -> col.getPropertyType() == PropertyType.ATTACHMENT))
93+
{
94+
// Add a select for the ObjectIds in this ExpDataClass if the table includes an attachment column.
95+
// ExpDataClass attachments use the LSID's ObjectId as the attachment parent EntityId, so we need
96+
// to use a SQL expression to extract it.
97+
selectStatements.add(
98+
new SQLFragment("\n SELECT ")
99+
.append(expressionToExtractObjectId)
100+
.append(" AS EntityId, ")
101+
.append(dialect.concatenate(
102+
new SQLFragment("?", domain.getName()),
103+
new SQLFragment("':'"),
104+
new SQLFragment("Name")
105+
))
106+
.append(" AS Description FROM expdataclass.")
107+
.append(domain.getStorageTableName())
108+
.append(" WHERE ").append(where)
109+
);
110+
}
111+
}
93112
});
94113

95114
return selectStatements.isEmpty() ?

0 commit comments

Comments
 (0)