using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Querying; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; using static Umbraco.Cms.Core.Persistence.SqlExtensionsStatics; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// /// Represents a repository for doing CRUD operations for /// public class MediaRepository : ContentRepositoryBase, IMediaRepository { private readonly AppCaches _cache; private readonly MediaByGuidReadRepository _mediaByGuidReadRepository; private readonly IMediaTypeRepository _mediaTypeRepository; private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; private readonly IJsonSerializer _serializer; private readonly ITagRepository _tagRepository; public MediaRepository( IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, ILoggerFactory loggerFactory, IMediaTypeRepository mediaTypeRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, PropertyEditorCollection propertyEditorCollection, MediaUrlGeneratorCollection mediaUrlGenerators, DataValueReferenceFactoryCollection dataValueReferenceFactories, IDataTypeService dataTypeService, IJsonSerializer serializer, IEventAggregator eventAggregator) : base(scopeAccessor, cache, logger, languageRepository, relationRepository, relationTypeRepository, propertyEditorCollection, dataValueReferenceFactories, dataTypeService, eventAggregator) { _cache = cache; _mediaTypeRepository = mediaTypeRepository ?? throw new ArgumentNullException(nameof(mediaTypeRepository)); _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); _mediaUrlGenerators = mediaUrlGenerators; _serializer = serializer; _mediaByGuidReadRepository = new MediaByGuidReadRepository(this, scopeAccessor, cache, loggerFactory.CreateLogger()); } protected override MediaRepository This => this; /// public override IEnumerable GetPage(IQuery? query, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering) { Sql? filterSql = null; if (filter != null) { filterSql = Sql(); foreach (Tuple clause in filter.GetWhereClauses()) { filterSql = filterSql.Append($"AND ({clause.Item1})", clause.Item2); } } return GetPage(query, pageIndex, pageSize, out totalRecords, x => MapDtosToContent(x), filterSql, ordering); } private IEnumerable MapDtosToContent(List dtos, bool withCache = false) { var temps = new List>(); var contentTypes = new Dictionary(); var content = new Core.Models.Media[dtos.Count]; for (var i = 0; i < dtos.Count; i++) { ContentDto dto = dtos[i]; if (withCache) { // if the cache contains the (proper version of the) item, use it IMedia? cached = IsolatedCache.GetCacheItem(RepositoryCacheKeys.GetKey(dto.NodeId)); if (cached != null && cached.VersionId == dto.ContentVersionDto.Id) { content[i] = (Core.Models.Media)cached; continue; } } // else, need to build it // get the content type - the repository is full cache *but* still deep-clones // whatever comes out of it, so use our own local index here to avoid this var contentTypeId = dto.ContentTypeId; if (contentTypes.TryGetValue(contentTypeId, out IMediaType? contentType) == false) { contentTypes[contentTypeId] = contentType = _mediaTypeRepository.Get(contentTypeId); } Core.Models.Media c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType); // need properties var versionId = dto.ContentVersionDto.Id; temps.Add(new TempContent(dto.NodeId, versionId, 0, contentType, c)); } // load all properties for all documents from database in 1 query - indexed by version id IDictionary properties = GetPropertyCollections(temps); // assign properties foreach (TempContent temp in temps) { if (temp.Content is not null) { temp.Content.Properties = properties[temp.VersionId]; // reset dirty initial properties (U4-1946) temp.Content.ResetDirtyProperties(false); } } return content; } private IMedia MapDtoToContent(ContentDto dto) { IMediaType? contentType = _mediaTypeRepository.Get(dto.ContentTypeId); Core.Models.Media media = ContentBaseFactory.BuildEntity(dto, contentType); // get properties - indexed by version id var versionId = dto.ContentVersionDto.Id; var temp = new TempContent(dto.NodeId, versionId, 0, contentType); IDictionary properties = GetPropertyCollections(new List> { temp }); media.Properties = properties[versionId]; // reset dirty initial properties (U4-1946) media.ResetDirtyProperties(false); return media; } #region Repository Base protected override Guid NodeObjectTypeId => Constants.ObjectTypes.Media; protected override IMedia? PerformGet(int id) { Sql sql = GetBaseQuery(QueryType.Single) .Where(x => x.NodeId == id) .SelectTop(1); ContentDto? dto = Database.Fetch(sql).FirstOrDefault(); return dto == null ? null : MapDtoToContent(dto); } protected override IEnumerable PerformGetAll(params int[]? ids) { Sql sql = GetBaseQuery(QueryType.Many); if (ids?.Any() ?? false) { sql.WhereIn(x => x.NodeId, ids); } return MapDtosToContent(Database.Fetch(sql)); } protected override IEnumerable PerformGetByQuery(IQuery query) { Sql sqlClause = GetBaseQuery(QueryType.Many); var translator = new SqlTranslator(sqlClause, query); Sql sql = translator.Translate(); sql .OrderBy(x => x.Level) .OrderBy(x => x.SortOrder); return MapDtosToContent(Database.Fetch(sql)); } protected override Sql GetBaseQuery(QueryType queryType) => GetBaseQuery(queryType); protected virtual Sql GetBaseQuery(QueryType queryType, bool current = true, bool joinMediaVersion = false) { Sql sql = SqlContext.Sql(); switch (queryType) { case QueryType.Count: sql = sql.SelectCount(); break; case QueryType.Ids: sql = sql.Select(x => x.NodeId); break; case QueryType.Single: case QueryType.Many: sql = sql.Select(r => r.Select(x => x.NodeDto) .Select(x => x.ContentVersionDto)) // ContentRepositoryBase expects a variantName field to order by name // for now, just return the plain invariant node name .AndSelect(x => Alias(x.Text, "variantName")); break; } sql .From() .InnerJoin().On(left => left.NodeId, right => right.NodeId) .InnerJoin() .On(left => left.NodeId, right => right.NodeId); if (joinMediaVersion) { sql.InnerJoin() .On((left, right) => left.Id == right.Id); } sql.Where(x => x.NodeObjectType == NodeObjectTypeId); if (current) { sql.Where(x => x.Current); // always get the current version } return sql; } protected override Sql GetBaseQuery(bool isCount) => GetBaseQuery(isCount ? QueryType.Count : QueryType.Single); // ah maybe not, that what's used for eg Exists in base repo protected override string GetBaseWhereClause() => $"{Constants.DatabaseSchema.Tables.Node}.id = @id"; protected override IEnumerable GetDeleteClauses() { var list = new List { "DELETE FROM " + Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2Node + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2NodePermission + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.UserStartNode + " WHERE startNode = @id", "UPDATE " + Constants.DatabaseSchema.Tables.UserGroup + " SET startContentId = NULL WHERE startContentId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE parentId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Relation + " WHERE childId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Document + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.MediaVersion + " WHERE id IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE versionId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id)", "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersion + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Content + " WHERE nodeId = @id", "DELETE FROM " + Constants.DatabaseSchema.Tables.Node + " WHERE id = @id", }; return list; } #endregion #region Versions public override IEnumerable GetAllVersions(int nodeId) { Sql sql = GetBaseQuery(QueryType.Many, false) .Where(x => x.NodeId == nodeId) .OrderByDescending(x => x.Current) .AndByDescending(x => x.VersionDate); return MapDtosToContent(Database.Fetch(sql), true); } public override IMedia? GetVersion(int versionId) { Sql sql = GetBaseQuery(QueryType.Single) .Where(x => x.Id == versionId); ContentDto? dto = Database.Fetch(sql).FirstOrDefault(); return dto == null ? null : MapDtoToContent(dto); } public IMedia? GetMediaByPath(string mediaPath) { var umbracoFileValue = mediaPath; const string pattern = ".*[_][0-9]+[x][0-9]+[.].*"; var isResized = Regex.IsMatch(mediaPath, pattern); // If the image has been resized we strip the "_403x328" of the original "/media/1024/koala_403x328.jpg" URL. if (isResized) { var underscoreIndex = mediaPath.LastIndexOf('_'); var dotIndex = mediaPath.LastIndexOf('.'); umbracoFileValue = string.Concat(mediaPath.Substring(0, underscoreIndex), mediaPath.Substring(dotIndex)); } Sql sql = GetBaseQuery(QueryType.Single, joinMediaVersion: true) .Where(x => x.Path == umbracoFileValue) .SelectTop(1); ContentDto? dto = Database.Fetch(sql).FirstOrDefault(); return dto == null ? null : MapDtoToContent(dto); } protected override void PerformDeleteVersion(int id, int versionId) { Database.Delete("WHERE versionId = @versionId", new { versionId }); Database.Delete("WHERE versionId = @versionId", new { versionId }); } #endregion #region Persist protected override void PersistNewItem(IMedia entity) { entity.AddingEntity(); // ensure unique name on the same level entity.Name = EnsureUniqueNodeName(entity.ParentId, entity.Name)!; // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? entity.SanitizeEntityPropertiesForXmlStorage(); // create the dto MediaDto dto = ContentBaseFactory.BuildDto(_mediaUrlGenerators, entity); // derive path and level from parent NodeDto parent = GetParentNodeDto(entity.ParentId); var level = parent.Level + 1; // get sort order var sortOrder = GetNewChildSortOrder(entity.ParentId, 0); // persist the node dto NodeDto nodeDto = dto.ContentDto.NodeDto; nodeDto.Path = parent.Path; nodeDto.Level = Convert.ToInt16(level); nodeDto.SortOrder = sortOrder; // see if there's a reserved identifier for this unique id // and then either update or insert the node dto var id = GetReservedId(nodeDto.UniqueId); if (id > 0) { nodeDto.NodeId = id; } else { Database.Insert(nodeDto); } nodeDto.Path = string.Concat(parent.Path, ",", nodeDto.NodeId); nodeDto.ValidatePathWithException(); Database.Update(nodeDto); // update entity entity.Id = nodeDto.NodeId; entity.Path = nodeDto.Path; entity.SortOrder = sortOrder; entity.Level = level; // persist the content dto ContentDto contentDto = dto.ContentDto; contentDto.NodeId = nodeDto.NodeId; Database.Insert(contentDto); // persist the content version dto // assumes a new version id and version date (modified date) has been set ContentVersionDto contentVersionDto = dto.MediaVersionDto.ContentVersionDto; contentVersionDto.NodeId = nodeDto.NodeId; contentVersionDto.Current = true; Database.Insert(contentVersionDto); entity.VersionId = contentVersionDto.Id; // persist the media version dto MediaVersionDto mediaVersionDto = dto.MediaVersionDto; mediaVersionDto.Id = entity.VersionId; Database.Insert(mediaVersionDto); // persist the property data InsertPropertyValues(entity, 0, out _, out _); // set tags SetEntityTags(entity, _tagRepository, _serializer); PersistRelations(entity); OnUowRefreshedEntity(new MediaRefreshNotification(entity, new EventMessages())); entity.ResetDirtyProperties(); } protected override void PersistUpdatedItem(IMedia entity) { // update entity.UpdatingEntity(); // Check if this entity is being moved as a descendant as part of a bulk moving operations. // In this case we can bypass a lot of the below operations which will make this whole operation go much faster. // When moving we don't need to create new versions, etc... because we cannot roll this operation back anyways. var isMoving = entity.IsMoving(); if (!isMoving) { // ensure unique name on the same level entity.Name = EnsureUniqueNodeName(entity.ParentId, entity.Name, entity.Id)!; // ensure that strings don't contain characters that are invalid in xml // TODO: do we really want to keep doing this here? entity.SanitizeEntityPropertiesForXmlStorage(); // if parent has changed, get path, level and sort order if (entity.IsPropertyDirty(nameof(entity.ParentId))) { NodeDto parent = GetParentNodeDto(entity.ParentId); entity.Path = string.Concat(parent.Path, ",", entity.Id); entity.Level = parent.Level + 1; entity.SortOrder = GetNewChildSortOrder(entity.ParentId, 0); } } // create the dto MediaDto dto = ContentBaseFactory.BuildDto(_mediaUrlGenerators, entity); // update the node dto NodeDto nodeDto = dto.ContentDto.NodeDto; nodeDto.ValidatePathWithException(); Database.Update(nodeDto); if (!isMoving) { // update the content dto Database.Update(dto.ContentDto); // update the content & media version dtos ContentVersionDto contentVersionDto = dto.MediaVersionDto.ContentVersionDto; MediaVersionDto mediaVersionDto = dto.MediaVersionDto; contentVersionDto.Current = true; Database.Update(contentVersionDto); Database.Update(mediaVersionDto); // replace the property data ReplacePropertyValues(entity, entity.VersionId, 0, out _, out _); SetEntityTags(entity, _tagRepository, _serializer); PersistRelations(entity); } OnUowRefreshedEntity(new MediaRefreshNotification(entity, new EventMessages())); entity.ResetDirtyProperties(); } protected override void PersistDeletedItem(IMedia entity) { // Raise event first else potential FK issues OnUowRemovingEntity(entity); base.PersistDeletedItem(entity); } #endregion #region Recycle Bin public override int RecycleBinId => Constants.System.RecycleBinMedia; public bool RecycleBinSmells() { IAppPolicyCache cache = _cache.RuntimeCache; var cacheKey = CacheKeys.MediaRecycleBinCacheKey; // always cache either true or false return cache.GetCacheItem(cacheKey, () => CountChildren(RecycleBinId) > 0); } #endregion #region Read Repository implementation for Guid keys public IMedia? Get(Guid id) => _mediaByGuidReadRepository.Get(id); IEnumerable IReadRepository.GetMany(params Guid[]? ids) => _mediaByGuidReadRepository.GetMany(ids); public bool Exists(Guid id) => _mediaByGuidReadRepository.Exists(id); // A reading repository purely for looking up by GUID // TODO: This is ugly and to fix we need to decouple the IRepositoryQueryable -> IRepository -> IReadRepository which should all be separate things! // This sub-repository pattern is super old and totally unecessary anymore, caching can be handled in much nicer ways without this private class MediaByGuidReadRepository : EntityRepositoryBase { private readonly MediaRepository _outerRepo; public MediaByGuidReadRepository(MediaRepository outerRepo, IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) : base(scopeAccessor, cache, logger) => _outerRepo = outerRepo; protected override IMedia? PerformGet(Guid id) { Sql sql = _outerRepo.GetBaseQuery(QueryType.Single) .Where(x => x.UniqueId == id); ContentDto? dto = Database.Fetch(sql.SelectTop(1)).FirstOrDefault(); if (dto == null) { return null; } IMedia content = _outerRepo.MapDtoToContent(dto); return content; } protected override IEnumerable PerformGetAll(params Guid[]? ids) { Sql sql = _outerRepo.GetBaseQuery(QueryType.Many); if (ids?.Length > 0) { sql.WhereIn(x => x.UniqueId, ids); } return _outerRepo.MapDtosToContent(Database.Fetch(sql)); } protected override IEnumerable PerformGetByQuery(IQuery query) => throw new InvalidOperationException("This method won't be implemented."); protected override IEnumerable GetDeleteClauses() => throw new InvalidOperationException("This method won't be implemented."); protected override void PersistNewItem(IMedia entity) => throw new InvalidOperationException("This method won't be implemented."); protected override void PersistUpdatedItem(IMedia entity) => throw new InvalidOperationException("This method won't be implemented."); protected override Sql GetBaseQuery(bool isCount) => throw new InvalidOperationException("This method won't be implemented."); protected override string GetBaseWhereClause() => throw new InvalidOperationException("This method won't be implemented."); } #endregion }