// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System.Globalization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Assets.Models; using Squidex.Assets; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets { /// /// Uploads and retrieves assets. /// [ApiExplorerSettings(GroupName = nameof(Assets))] public sealed class AssetContentController : ApiController { private readonly IAssetFileStore assetFileStore; private readonly IAssetQueryService assetQuery; private readonly IAssetLoader assetLoader; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; public AssetContentController( ICommandBus commandBus, IAssetFileStore assetFileStore, IAssetQueryService assetQuery, IAssetLoader assetLoader, IAssetThumbnailGenerator assetThumbnailGenerator) : base(commandBus) { this.assetFileStore = assetFileStore; this.assetQuery = assetQuery; this.assetLoader = assetLoader; this.assetThumbnailGenerator = assetThumbnailGenerator; } /// /// Get the asset content. /// /// The name of the app. /// The id or slug of the asset. /// The request parameters. /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. /// /// 200 => Asset found and content or (resized) image returned. /// 404 => Asset or app not found. /// [HttpGet] [Route("assets/{app}/{idOrSlug}/{*more}")] [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ApiPermission] [ApiCosts(0.5)] [AllowAnonymous] public async Task GetAssetContentBySlug(string app, string idOrSlug, AssetContentQueryDto request, string? more = null) { var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug), ct: HttpContext.RequestAborted); if (asset == null) { asset = await assetQuery.FindBySlugAsync(requestContext, idOrSlug, HttpContext.RequestAborted); } return await DeliverAssetAsync(requestContext, asset, request); } /// /// Get the asset content. /// /// The id of the asset. /// The request parameters. /// /// 200 => Asset found and content or (resized) image returned. /// 404 => Asset or app not found. /// [HttpGet] [Route("assets/{id}/")] [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ApiPermission] [ApiCosts(0.5)] [AllowAnonymous] [Obsolete("Use overload with app name")] public async Task GetAssetContent(DomainId id, AssetContentQueryDto request) { var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); var asset = await assetQuery.FindGlobalAsync(requestContext, id, HttpContext.RequestAborted); return await DeliverAssetAsync(requestContext, asset, request); } private async Task DeliverAssetAsync(Context context, IAssetEntity? asset, AssetContentQueryDto request) { request ??= new AssetContentQueryDto(); if (asset == null) { return NotFound(); } if (asset.IsProtected && !Resources.CanReadAssets) { Response.Headers[HeaderNames.CacheControl] = "public,max-age=0"; return StatusCode(403); } if (asset != null && request.Version > EtagVersion.Any && asset.Version != request.Version) { if (context.App != null) { asset = await assetQuery.FindAsync(context, asset.Id, request.Version, HttpContext.RequestAborted); } else { // Fallback for old endpoint. Does not set the surrogate key. asset = await assetLoader.GetAsync(asset.AppId.Id, asset.Id, request.Version); } } if (asset == null) { return NotFound(); } Response.Headers[HeaderNames.ETag] = asset.FileVersion.ToString(CultureInfo.InvariantCulture); if (request.CacheDuration > 0) { Response.Headers[HeaderNames.CacheControl] = $"public,max-age={request.CacheDuration}"; } var resizeOptions = request.ToResizeOptions(asset, HttpContext.Request); var contentLength = (long?)null; var contentCallback = (FileCallback?)null; var contentType = asset.MimeType; if (asset.Type == AssetType.Image && assetThumbnailGenerator.IsResizable(asset.MimeType, resizeOptions, out var destinationMimeType)) { contentType = destinationMimeType!; contentCallback = async (body, range, ct) => { var suffix = resizeOptions.ToString(); if (request.Force) { using (Telemetry.Activities.StartActivity("Resize")) { await ResizeAsync(asset, suffix, body, resizeOptions, true, ct); } } else { try { await DownloadAsync(asset, body, suffix, range, ct); } catch (AssetNotFoundException) { await ResizeAsync(asset, suffix, body, resizeOptions, false, ct); } } }; } else { contentLength = asset.FileSize; contentCallback = async (body, range, ct) => { await DownloadAsync(asset, body, null, range, ct); }; } return new FileCallbackResult(contentType, contentCallback) { EnableRangeProcessing = contentLength > 0, ErrorAs404 = true, FileDownloadName = asset.FileName, FileSize = contentLength, LastModified = asset.LastModified.ToDateTimeOffset(), SendInline = request.Download != 1 }; } private async Task DownloadAsync(IAssetEntity asset, Stream bodyStream, string? suffix, BytesRange range, CancellationToken ct) { await assetFileStore.DownloadAsync(asset.AppId.Id, asset.Id, asset.FileVersion, suffix, bodyStream, range, ct); } private async Task ResizeAsync(IAssetEntity asset, string suffix, Stream target, ResizeOptions resizeOptions, bool overwrite, CancellationToken ct) { #pragma warning disable MA0040 // Flow the cancellation token using var activity = Telemetry.Activities.StartActivity("Resize"); await using var assetOriginal = new TempAssetFile(asset.FileName, asset.MimeType, 0); await using var assetResized = new TempAssetFile(asset.FileName, asset.MimeType, 0); using (Telemetry.Activities.StartActivity("Read")) { await using (var originalStream = assetOriginal.OpenWrite()) { await assetFileStore.DownloadAsync(asset.AppId.Id, asset.Id, asset.FileVersion, null, originalStream); } } using (Telemetry.Activities.StartActivity("Resize")) { try { await using (var originalStream = assetOriginal.OpenRead()) { await using (var resizeStream = assetResized.OpenWrite()) { await assetThumbnailGenerator.CreateThumbnailAsync(originalStream, asset.MimeType, resizeStream, resizeOptions); } } } catch { await using (var originalStream = assetOriginal.OpenRead()) { await using (var resizeStream = assetResized.OpenWrite()) { await originalStream.CopyToAsync(resizeStream); } } } } using (Telemetry.Activities.StartActivity("Save")) { try { await using (var resizeStream = assetResized.OpenRead()) { await assetFileStore.UploadAsync(asset.AppId.Id, asset.Id, asset.FileVersion, suffix, resizeStream, overwrite); } } catch (AssetAlreadyExistsException) { return; } } using (Telemetry.Activities.StartActivity("Write")) { await using (var resizeStream = assetResized.OpenRead()) { await resizeStream.CopyToAsync(target, ct); } } #pragma warning restore MA0040 // Flow the cancellation token } } }