diff --git a/Nuget.config b/Nuget.config
new file mode 100644
index 000000000..b1738fc41
--- /dev/null
+++ b/Nuget.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Squidex.Events/Assets/AssetCreated.cs b/src/Squidex.Events/Assets/AssetCreated.cs
index 377609575..b673adb28 100644
--- a/src/Squidex.Events/Assets/AssetCreated.cs
+++ b/src/Squidex.Events/Assets/AssetCreated.cs
@@ -14,5 +14,11 @@ namespace Squidex.Events.Assets
public class AssetCreated : AssetEvent
{
public string Name { get; set; }
+
+ public string MimeType { get; set; }
+
+ public long FileSize { get; set; }
+
+ public bool IsImage { get; set; }
}
}
diff --git a/src/Squidex.Events/Assets/AssetEvent.cs b/src/Squidex.Events/Assets/AssetEvent.cs
index f09c85a94..9fb442919 100644
--- a/src/Squidex.Events/Assets/AssetEvent.cs
+++ b/src/Squidex.Events/Assets/AssetEvent.cs
@@ -7,11 +7,10 @@
// ==========================================================================
using System;
-using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Assets
{
- public abstract class AssetEvent : IEvent
+ public abstract class AssetEvent : AppEvent
{
public Guid AssetId { get; set; }
}
diff --git a/src/Squidex.Events/Assets/AssetUpdated.cs b/src/Squidex.Events/Assets/AssetUpdated.cs
new file mode 100644
index 000000000..4e2bf8677
--- /dev/null
+++ b/src/Squidex.Events/Assets/AssetUpdated.cs
@@ -0,0 +1,22 @@
+// ==========================================================================
+// AssetUpdated.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Events.Assets
+{
+ [TypeName("AssetUpdated")]
+ public class AssetUpdated : AssetEvent
+ {
+ public string MimeType { get; set; }
+
+ public long FileSize { get; set; }
+
+ public bool IsImage { get; set; }
+ }
+}
diff --git a/src/Squidex.Infrastructure/Images/IAssetStorage.cs b/src/Squidex.Infrastructure/Images/IAssetStorage.cs
new file mode 100644
index 000000000..0a5dfdf33
--- /dev/null
+++ b/src/Squidex.Infrastructure/Images/IAssetStorage.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// IAssetStorage.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Squidex.Infrastructure.Images
+{
+ public interface IAssetStorage
+ {
+ Task GetAssetAsync(Guid id, string tags = null);
+
+ Task UploadAssetAsync(Guid id, Stream stream, string tags = null);
+ }
+}
\ No newline at end of file
diff --git a/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs b/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs
new file mode 100644
index 000000000..66b22eceb
--- /dev/null
+++ b/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// IThumbnailGenerator.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Squidex.Infrastructure.Images
+{
+ public interface IThumbnailGenerator
+ {
+ Task GetThumbnailOrNullAsync(Stream input, int dimension);
+ }
+}
diff --git a/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs b/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs
new file mode 100644
index 000000000..54c0af791
--- /dev/null
+++ b/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs
@@ -0,0 +1,46 @@
+// ==========================================================================
+// ImageSharpThumbnailGenerator.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.IO;
+using System.Threading.Tasks;
+using ImageSharp;
+using ImageSharp.Formats;
+using ImageSharp.Processing;
+
+namespace Squidex.Infrastructure.Images.ImageSharp
+{
+ public sealed class ImageSharpThumbnailGenerator : IThumbnailGenerator
+ {
+ public ImageSharpThumbnailGenerator()
+ {
+ Configuration.Default.AddImageFormat(new JpegFormat());
+ Configuration.Default.AddImageFormat(new PngFormat());
+ }
+
+ public Task GetThumbnailOrNullAsync(Stream input, int dimension)
+ {
+ return Task.Run(() =>
+ {
+ var result = new MemoryStream();
+
+ var options =
+ new ResizeOptions
+ {
+ Size = new Size(dimension, dimension),
+ Mode = ResizeMode.Max
+ };
+
+ var image = new Image(input).Resize(options);
+
+ image.Save(result);
+
+ return result;
+ });
+ }
+ }
+}
diff --git a/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs b/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs
new file mode 100644
index 000000000..fe3fe89f1
--- /dev/null
+++ b/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs
@@ -0,0 +1,84 @@
+// ==========================================================================
+// PhysicalAssetStorage.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Squidex.Infrastructure.Images.Physical
+{
+ public sealed class PhysicalAssetStorage : IAssetStorage, IExternalSystem
+ {
+ private readonly DirectoryInfo directory;
+
+ public PhysicalAssetStorage(string path)
+ {
+ Guard.NotNullOrEmpty(path, nameof(path));
+
+ directory = new DirectoryInfo(path);
+ }
+
+ public void Connect()
+ {
+ try
+ {
+ if (!directory.Exists)
+ {
+ directory.Create();
+ }
+ }
+ catch
+ {
+ if (!directory.Exists)
+ {
+ throw new ConfigurationException($"Cannot access directory {directory.FullName}");
+ }
+ }
+ }
+
+ public Task GetAssetAsync(Guid id, string tags = null)
+ {
+ var file = GetFile(id, tags);
+
+ Stream stream = null;
+
+ if (file.Exists)
+ {
+ stream = file.OpenRead();
+ }
+
+ return Task.FromResult(stream);
+ }
+
+ public async Task UploadAssetAsync(Guid id, Stream stream, string tags = null)
+ {
+ var file = GetFile(id, tags);
+
+ using (var fileStream = file.OpenWrite())
+ {
+ await stream.CopyToAsync(fileStream);
+ }
+ }
+
+ private FileInfo GetFile(Guid id, string tags)
+ {
+ var fileName = id.ToString();
+
+ if (!string.IsNullOrWhiteSpace(tags))
+ {
+ fileName += tags;
+ }
+
+ Guard.ValidFileName(fileName, tags);
+
+ var file = new FileInfo(Path.Combine(directory.FullName, fileName));
+
+ return file;
+ }
+ }
+}
diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
index b9208aeb0..42928d91f 100644
--- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
+++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
@@ -7,6 +7,10 @@
True
+
+
+
+
diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs
new file mode 100644
index 000000000..69584033f
--- /dev/null
+++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using MongoDB.Bson.Serialization.Attributes;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.MongoDb;
+using Squidex.Read.Assets;
+
+namespace Squidex.Read.MongoDb.Assets
+{
+ public sealed class MongoAssetEntity : MongoEntity, IAssetEntity
+ {
+ [BsonRequired]
+ [BsonElement]
+ public string MimeType { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string Name { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long FileSize { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long Version { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public Guid AppId { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public RefToken CreatedBy { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public RefToken LastModifiedBy { get; set; }
+ }
+}
diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs
new file mode 100644
index 000000000..ebd1755c8
--- /dev/null
+++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs
@@ -0,0 +1,78 @@
+// ==========================================================================
+// MongoAssetRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using Squidex.Infrastructure.MongoDb;
+using Squidex.Read.Assets;
+using Squidex.Read.Assets.Repositories;
+
+namespace Squidex.Read.MongoDb.Assets
+{
+ public partial class MongoAssetRepository : MongoRepositoryBase, IAssetRepository
+ {
+ public MongoAssetRepository(IMongoDatabase database)
+ : base(database)
+ {
+ }
+
+ public async Task> QueryAsync(Guid appId, HashSet mimeTypes = null, string query = null, int take = 10, int skip = 0)
+ {
+ var filter = CreateFilter(appId, mimeTypes, query);
+
+ var assets =
+ await Collection.Find(filter).Skip(skip).Limit(take).ToListAsync();
+
+ return assets.OfType().ToList();
+ }
+
+ public async Task CountAsync(Guid appId, HashSet mimeTypes = null, string query = null)
+ {
+ var filter = CreateFilter(appId, mimeTypes, query);
+
+ var count =
+ await Collection.Find(filter).CountAsync();
+
+ return count;
+ }
+
+ public async Task FindAssetAsync(Guid id)
+ {
+ var entity =
+ await Collection.Find(s => s.Id == id).FirstOrDefaultAsync();
+
+ return entity;
+ }
+
+ private static FilterDefinition CreateFilter(Guid appId, HashSet mimeTypes, string query)
+ {
+ var filters = new List>
+ {
+ Filter.Eq(x => x.AppId, appId)
+ };
+
+ if (mimeTypes != null && mimeTypes.Count > 0)
+ {
+ filters.Add(Filter.In(x => x.MimeType, mimeTypes));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ filters.Add(Filter.Regex(x => x.Name, new BsonRegularExpression(query, "i")));
+ }
+
+ var filter = Filter.And(filters);
+
+ return filter;
+ }
+ }
+}
diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs
new file mode 100644
index 000000000..ec2c5acc3
--- /dev/null
+++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading.Tasks;
+using Squidex.Events.Apps;
+using Squidex.Events.Assets;
+using Squidex.Infrastructure.CQRS.Events;
+using Squidex.Infrastructure.Dispatching;
+using Squidex.Infrastructure.Reflection;
+using Squidex.Read.MongoDb.Utils;
+
+namespace Squidex.Read.MongoDb.Assets
+{
+ public partial class MongoAssetRepository
+ {
+ public Task On(Envelope @event)
+ {
+ return this.DispatchActionAsync(@event.Payload, @event.Headers);
+ }
+
+ protected Task On(AssetCreated @event, EnvelopeHeaders headers)
+ {
+ return Collection.CreateAsync(@event, headers, a =>
+ {
+ SimpleMapper.Map(@event, a);
+ });
+ }
+
+ protected Task On(AssetUpdated @event, EnvelopeHeaders headers)
+ {
+ return Collection.UpdateAsync(@event, headers, a =>
+ {
+ SimpleMapper.Map(@event, a);
+ });
+ }
+
+ protected Task On(AssetRenamed @event, EnvelopeHeaders headers)
+ {
+ return Collection.UpdateAsync(@event, headers, a =>
+ {
+ SimpleMapper.Map(@event, a);
+ });
+ }
+
+ protected Task On(AssetDeleted @event, EnvelopeHeaders headers)
+ {
+ return Collection.DeleteOneAsync(Filter.Eq(x => x.Id, @event.AssetId));
+ }
+ }
+}
diff --git a/src/Squidex.Read/Assets/IAssetEntity.cs b/src/Squidex.Read/Assets/IAssetEntity.cs
new file mode 100644
index 000000000..0dd5a9260
--- /dev/null
+++ b/src/Squidex.Read/Assets/IAssetEntity.cs
@@ -0,0 +1,19 @@
+// ==========================================================================
+// IAssetEntity.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Read.Assets
+{
+ public interface IAssetEntity : IAppRefEntity, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion
+ {
+ string MimeType { get; }
+
+ string Name { get; }
+
+ long FileSize { get; }
+ }
+}
diff --git a/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs
new file mode 100644
index 000000000..f01676bc2
--- /dev/null
+++ b/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// IAssetRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Squidex.Read.Assets.Repositories
+{
+ public interface IAssetRepository
+ {
+ Task> QueryAsync(Guid appId, HashSet mimeTypes = null, string query = null, int take = 10, int skip = 0);
+
+ Task CountAsync(Guid appId, HashSet mimeTypes = null, string query = null);
+
+ Task FindAssetAsync(Guid id);
+ }
+}
diff --git a/src/Squidex.Write/Assets/AssetDomainObject.cs b/src/Squidex.Write/Assets/AssetDomainObject.cs
index fffa687fe..7fec488ea 100644
--- a/src/Squidex.Write/Assets/AssetDomainObject.cs
+++ b/src/Squidex.Write/Assets/AssetDomainObject.cs
@@ -15,21 +15,23 @@ using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.Reflection;
using Squidex.Write.Assets.Commands;
+// ReSharper disable UnusedParameter.Local
+
namespace Squidex.Write.Assets
{
- public class AssetDomainObject : DomainObject
+ public class AssetDomainObject : DomainObjectBase
{
private bool isDeleted;
- private string name;
+ private string fileName;
public bool IsDeleted
{
get { return isDeleted; }
}
- public string Name
+ public string FileName
{
- get { return name; }
+ get { return fileName; }
}
public AssetDomainObject(Guid id, int version)
@@ -39,12 +41,12 @@ namespace Squidex.Write.Assets
protected void On(AssetCreated @event)
{
- name = @event.Name;
+ fileName = @event.Name;
}
protected void On(AssetRenamed @event)
{
- name = @event.Name;
+ fileName = @event.Name;
}
protected void On(AssetDeleted @event)
@@ -54,11 +56,11 @@ namespace Squidex.Write.Assets
public AssetDomainObject Create(CreateAsset command)
{
- Guard.Valid(command, nameof(command), () => "Cannot create content");
+ Guard.NotNull(command, nameof(command));
VerifyNotCreated();
- RaiseEvent(SimpleMapper.Map(command, new AssetCreated()));
+ RaiseEvent(SimpleMapper.Map(command, new AssetCreated { Name = command.FileName }));
return this;
}
@@ -88,7 +90,7 @@ namespace Squidex.Write.Assets
private void VerifyDifferentNames(string newName, Func message)
{
- if (string.Equals(name, newName))
+ if (string.Equals(fileName, newName))
{
throw new ValidationException(message(), new ValidationError("The asset already has this name.", "Name"));
}
@@ -96,7 +98,7 @@ namespace Squidex.Write.Assets
private void VerifyNotCreated()
{
- if (!string.IsNullOrWhiteSpace(name))
+ if (!string.IsNullOrWhiteSpace(fileName))
{
throw new DomainException("Asset has already been created.");
}
@@ -104,7 +106,7 @@ namespace Squidex.Write.Assets
private void VerifyCreatedAndNotDeleted()
{
- if (isDeleted || !string.IsNullOrWhiteSpace(name))
+ if (isDeleted || !string.IsNullOrWhiteSpace(fileName))
{
throw new DomainException("Asset has already been deleted or not created yet.");
}
diff --git a/src/Squidex.Write/Assets/Commands/CreateAsset.cs b/src/Squidex.Write/Assets/Commands/CreateAsset.cs
index 74b9af323..ef6977e30 100644
--- a/src/Squidex.Write/Assets/Commands/CreateAsset.cs
+++ b/src/Squidex.Write/Assets/Commands/CreateAsset.cs
@@ -6,21 +6,16 @@
// All rights reserved.
// ==========================================================================
-using System.Collections.Generic;
-using Squidex.Infrastructure;
-
namespace Squidex.Write.Assets.Commands
{
- public sealed class CreateAsset : AssetAggregateCommand, IValidatable
+ public sealed class CreateAsset : AssetAggregateCommand
{
- public string Name { get; set; }
+ public string FileName { get; set; }
+
+ public string MimeType { get; set; }
+
+ public long FileSize { get; set; }
- public void Validate(IList errors)
- {
- if (!Name.IsSlug())
- {
- errors.Add(new ValidationError("Name must be a valid slug", nameof(Name)));
- }
- }
+ public bool IsImage { get; set; }
}
}
diff --git a/src/Squidex/Controllers/Api/Assets/AssetConfig.cs b/src/Squidex/Controllers/Api/Assets/AssetConfig.cs
new file mode 100644
index 000000000..1e4ec5a14
--- /dev/null
+++ b/src/Squidex/Controllers/Api/Assets/AssetConfig.cs
@@ -0,0 +1,15 @@
+// ==========================================================================
+// AssetConfig.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Controllers.Api.Assets
+{
+ public sealed class AssetConfig
+ {
+ public long MaxSize { get; set; } = 5 * 1024 * 1024;
+ }
+}
diff --git a/src/Squidex/Controllers/Api/Assets/AssetsController.cs b/src/Squidex/Controllers/Api/Assets/AssetsController.cs
new file mode 100644
index 000000000..6d7bca1a8
--- /dev/null
+++ b/src/Squidex/Controllers/Api/Assets/AssetsController.cs
@@ -0,0 +1,110 @@
+// ==========================================================================
+// AssetsController.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using NSwag.Annotations;
+using Squidex.Core.Identity;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS.Commands;
+using Squidex.Infrastructure.Images;
+using Squidex.Pipeline;
+using Squidex.Write.Assets.Commands;
+
+namespace Squidex.Controllers.Api.Assets
+{
+ ///
+ /// Uploads and retrieves assets.
+ ///
+ [Authorize(Roles = SquidexRoles.AppEditor)]
+ [ApiExceptionFilter]
+ [ServiceFilter(typeof(AppFilterAttribute))]
+ [SwaggerTag("Assets")]
+ public class AssetsController : ControllerBase
+ {
+ private readonly IAssetStorage assetStorage;
+ private readonly AssetConfig assetsConfig;
+ private readonly IThumbnailGenerator thumbnailGenerator;
+
+ public AssetsController(
+ ICommandBus commandBus,
+ IAssetStorage assetStorage,
+ IThumbnailGenerator thumbnailGenerator,
+ IOptions assetsConfig)
+ : base(commandBus)
+ {
+ this.assetStorage = assetStorage;
+ this.assetsConfig = assetsConfig.Value;
+ this.thumbnailGenerator = thumbnailGenerator;
+ }
+
+ ///
+ /// Creates and uploads a new asset.
+ ///
+ /// The name of the app.
+ /// The name of the schema.
+ ///
+ /// 201 => Asset created.
+ /// 404 => App not found.
+ /// 400 => Asset exceeds the maximum size.
+ ///
+ [HttpPost]
+ [Route("apps/{app}/schemas/{name}/fields/")]
+ [ProducesResponseType(typeof(EntityCreatedDto), 201)]
+ [ProducesResponseType(typeof(ErrorDto), 409)]
+ [ProducesResponseType(typeof(ErrorDto), 400)]
+ public async Task PostAsset(string app, IFormFile file)
+ {
+ if (file.Length > assetsConfig.MaxSize)
+ {
+ var error = new ValidationError($"File size cannot be longer than ${assetsConfig.MaxSize}.");
+
+ throw new ValidationException("Cannot create asset.", error);
+ }
+
+ var command = new CreateAsset
+ {
+ AssetId = Guid.NewGuid(),
+ FileSize = file.Length,
+ FileName = file.Name,
+ MimeType = file.ContentType
+ };
+
+ var fileContent = new MemoryStream();
+
+ await file.CopyToAsync(fileContent);
+
+ fileContent.Position = 0;
+
+ var fileThumbnail = await thumbnailGenerator.GetThumbnailOrNullAsync(fileContent, 200);
+
+ if (fileThumbnail != null)
+ {
+ command.IsImage = true;
+
+ await assetStorage.UploadAssetAsync(command.AssetId, fileThumbnail, "thumbnail");
+ }
+
+ fileContent.Position = 0;
+
+ await assetStorage.UploadAssetAsync(command.AssetId, fileContent);
+
+ var context = await CommandBus.PublishAsync(command);
+
+ var result = context.Result>().IdOrValue;
+ var response = new EntityCreatedDto { Id = result.ToString() };
+
+ return StatusCode(201, response);
+ }
+ }
+}