Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: upload static images to be used by the image component #13322

Merged
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
056ab9f
Add endpoints for image handling and frontend for uploading from imag…
standeren Jul 30, 2024
c582c06
Handle no existing images in wwwroot
standeren Aug 6, 2024
b98c785
Add wwwroot to src and handle preview when part of src path
standeren Aug 7, 2024
6ac5ac2
Handle delete
standeren Aug 8, 2024
40b6afd
Handle use library for images
standeren Aug 9, 2024
b9343a6
Handle use library for images
standeren Aug 9, 2024
b303cfe
Validate external url using backend call
standeren Aug 13, 2024
44c84f7
Fix PR comments
standeren Aug 14, 2024
906a819
Fix PR comments
standeren Aug 15, 2024
7ea4fff
add tests
standeren Aug 15, 2024
deaaa9d
Fix revalidation and tests
standeren Aug 28, 2024
04a8a4d
Add backend tests and ensure files can exists anywhere in wwwroot
standeren Aug 29, 2024
dd877d8
Fix PR comments
standeren Aug 30, 2024
4e058bb
Use AltinnRepoEditingContext
standeren Sep 2, 2024
f8528a7
Fix PR comments
standeren Sep 3, 2024
2f68130
Fix PR comments
standeren Sep 4, 2024
19a1f34
Fix PR comments
standeren Sep 16, 2024
ff2e8c2
Use studioFileUploader
standeren Sep 16, 2024
b1055f8
Remove unused code
standeren Sep 16, 2024
2e33ddf
Fix tests
standeren Sep 16, 2024
1d97a82
Use new studioModal
standeren Sep 17, 2024
376e34f
Remove irrelevant changes
standeren Sep 17, 2024
0d04532
Remove description placeholder for images
standeren Sep 17, 2024
10245c2
split chooseFromLibrary into more components
standeren Sep 18, 2024
9085d34
Update frontend/language/src/nb.json
standeren Sep 19, 2024
8cee697
Fix PR comments
standeren Sep 19, 2024
18cca10
Update frontend/packages/ux-editor/src/components/config/editModal/Ed…
standeren Sep 20, 2024
b013be5
Fix PR comments
standeren Sep 20, 2024
65b73ba
Remove consolelog
standeren Sep 20, 2024
07722d8
Fix comments after test
standeren Sep 25, 2024
79d9b96
fix test that check if url is deleted when entering empty string
standeren Sep 25, 2024
38ec507
Add filesize validation in studioFileUploader
standeren Sep 26, 2024
75c5f70
Add test for uploading a file that is smaller than fileSizeLimit
standeren Sep 26, 2024
a8e8e18
Add test for useAddImageMutation and useDeleteImageMutation
standeren Sep 26, 2024
c5cc06a
Fix PR comments
standeren Sep 26, 2024
73abd19
Add same fileExtension restrictions in FE as in BE
standeren Sep 26, 2024
8f9ba7c
Add comment that fileExtensionList must be synced or fetched from new…
standeren Sep 26, 2024
c82ee58
Add tests
standeren Sep 26, 2024
3c3a106
Merge branch 'main' into 12848-upload-static-images-to-be-used-by-the…
standeren Sep 26, 2024
46f1d65
Add tests for text.tsx
standeren Sep 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions backend/src/Designer/Controllers/ImageController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Web;
using Altinn.Studio.Designer.Enums;
using Altinn.Studio.Designer.Exceptions.AppDevelopment;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
using Altinn.Studio.Designer.TypedHttpClients.ImageClient;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

namespace Altinn.Studio.Designer.Controllers;

/// <summary>
/// Controller containing actions related to images
/// </summary>
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("designer/api/{org}/{app:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/images")]
public class ImageController : ControllerBase
{

private readonly IImagesService _imagesService;
private readonly ImageClient _imageClient;

/// <summary>
/// Initializes a new instance of the <see cref="ImageController"/> class.
/// </summary>
/// <param name="imagesService">The images service.</param>
/// <param name="imageClient">A http client to validate external image url</param>
public ImageController(IImagesService imagesService, ImageClient imageClient)
{
_imagesService = imagesService;
_imageClient = imageClient;
}

/// <summary>
/// Endpoint for getting a specific image
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="app">Application identifier which is unique within an organisation.</param>
/// <param name="encodedImagePath">Relative encoded path of image to fetch</param>
/// <returns>Image</returns>
[HttpGet("{encodedImagePath}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public FileStreamResult GetImageByName(string org, string app, [FromRoute] string encodedImagePath)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
string decodedImagePath = HttpUtility.UrlDecode(encodedImagePath);
var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer);

return _imagesService.GetImage(editingContext, decodedImagePath);
}

/// <summary>
/// Endpoint for getting all image file names in application
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="app">Application identifier which is unique within an organisation.</param>
/// <returns>All image file names</returns>
[HttpGet("fileNames")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<List<string>> GetAllImagesFileNames(string org, string app)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer);

List<string> imageFileNames = _imagesService.GetAllImageFileNames(editingContext);

return Ok(imageFileNames);
}

/// <summary>
/// Endpoint to validate a given url for fetching an external image.
/// </summary>
/// <param name="url">An external url to fetch an image to represent in the image component in the form.</param>
/// <returns>NotAnImage if url does not point at an image or NotValidUrl if url is invalid for any other reason</returns>
[HttpGet("validate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ImageUrlValidationResult> ValidateExternalImageUrl([FromQuery] string url)
{
return await _imageClient.ValidateUrlAsync(url);
}

/// <summary>
/// Endpoint for uploading image to application.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="app">Application identifier which is unique within an organisation.</param>
/// <param name="image">The actual image</param>
/// <param name="overrideExisting">Optional parameter that overrides existing image if set. Default is false</param>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> UploadImage(string org, string app, [FromForm(Name = "file")] IFormFile image, [FromForm(Name = "overrideExisting")] bool overrideExisting = false)
{
if (image == null || image.Length == 0)
{
return BadRequest("No file uploaded.");
}
if (!IsValidImageContentType(image.ContentType))
{
throw new InvalidExtensionImageUploadException("The uploaded file is not a valid image.");
}

string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);

string imageName = GetFileNameFromUploadedFile(image);
try
{
var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer);
await _imagesService.UploadImage(editingContext, imageName, image.OpenReadStream(), overrideExisting);
return NoContent();
}
catch (InvalidOperationException e)
{
return BadRequest(e.Message);
}
}

/// <summary>
/// Endpoint for deleting image from application.
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="app">Application identifier which is unique within an organisation.</param>
/// <param name="encodedImagePath">Relative encoded path of image to delete</param>
[HttpDelete("{encodedImagePath}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult> DeleteImage(string org, string app, [FromRoute] string encodedImagePath)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
string decodedImagePath = HttpUtility.UrlDecode(encodedImagePath);
var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer);

await _imagesService.DeleteImage(editingContext, decodedImagePath);

return NoContent();
}

private static string GetFileNameFromUploadedFile(IFormFile image)
{
return ContentDispositionHeaderValue.Parse(new StringSegment(image.ContentDisposition)).FileName.ToString();
}

private bool IsValidImageContentType(string contentType)
{
return contentType.ToLower().StartsWith("image/");
}

}
2 changes: 1 addition & 1 deletion backend/src/Designer/Controllers/PreviewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public FileStreamResult Image(string org, string app, string imageFilePath, Canc

string developer = AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext);
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer);
Stream imageStream = altinnAppGitRepository.GetImage(imageFilePath);
Stream imageStream = altinnAppGitRepository.GetImageAsStreamByFilePath(imageFilePath);
return new FileStreamResult(imageStream, MimeTypeMap.GetMimeType(Path.GetExtension(imageFilePath).ToLower()));
}

Expand Down
18 changes: 18 additions & 0 deletions backend/src/Designer/Enums/ImageUrlValidationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Runtime.Serialization;

namespace Altinn.Studio.Designer.Enums;

/// <summary>
/// ImageUrlValidationResult
/// </summary>
public enum ImageUrlValidationResult
{
[EnumMember(Value = "Ok")]
Ok,

[EnumMember(Value = "NotAnImage")]
NotAnImage,

[EnumMember(Value = "NotValidUrl")]
NotValidUrl
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace Altinn.Studio.Designer.Exceptions.AppDevelopment;

/// <summary>
/// Indicates that a file was uploaded with the a conflicting file name
/// </summary>
[Serializable]
public class ConflictingFileNameException : Exception
{
/// <inheritdoc/>
public ConflictingFileNameException()
{
}

/// <inheritdoc/>
public ConflictingFileNameException(string message) : base(message)
{
}

/// <inheritdoc/>
public ConflictingFileNameException(string message, Exception innerException) : base(message, innerException)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;

namespace Altinn.Studio.Designer.Exceptions.AppDevelopment
{
/// <summary>
/// Indicates that an image with invalid extension was uploaded
/// </summary>
[Serializable]
public class InvalidExtensionImageUploadException : Exception
{
/// <inheritdoc/>
public InvalidExtensionImageUploadException()
{
}

/// <inheritdoc/>
public InvalidExtensionImageUploadException(string message) : base(message)
{
}

/// <inheritdoc/>
public InvalidExtensionImageUploadException(string message, Exception innerException) : base(message, innerException)
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Altinn.Studio.Designer.Exceptions.AppDevelopment
{
/// <summary>
/// Indicates that an error occurred during C# code generation.
/// Indicates that a layout set id is invalid
/// </summary>
[Serializable]
public class InvalidLayoutSetIdException : Exception
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ public class AppDevelopmentErrorCodes
public const string NonUniqueLayoutSetIdError = "AD_01";
public const string NonUniqueTaskForLayoutSetError = "AD_02";
public const string EmptyLayoutSetIdError = "AD_03";
public const string ConflictingFileNameError = "AD_04";
public const string UploadedImageNotValid = nameof(UploadedImageNotValid);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public override void OnException(ExceptionContext context)
{
context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, AppDevelopmentErrorCodes.EmptyLayoutSetIdError, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest };
}
if (context.Exception is ConflictingFileNameException)
{
context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, AppDevelopmentErrorCodes.ConflictingFileNameError, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest };
}
if (context.Exception is InvalidExtensionImageUploadException)
{
context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, AppDevelopmentErrorCodes.UploadedImageNotValid, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest };
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ public override async Task<string> SaveXsd(string xsd, string fileName)
return filePath;
}

/// <summary>
/// Saves the image to the disk.
/// </summary>
/// <param name="image">Stream representing the image to be saved.</param>
/// <param name="imageFileName">The file name of the image to be saved.</param>
/// <returns>A string containing the relative path to the file saved.</returns>
public async Task<string> SaveImageAsMemoryStream(MemoryStream image, string imageFileName)
{
string filePath = Path.Combine(ImagesFolderName, imageFileName);
image.Position = 0;
await WriteStreamByRelativePathAsync(filePath, image, true);
image.Position = 0;

return filePath;
}

/// <summary>
/// Gets the folder where the data models are stored.
/// </summary>
Expand Down Expand Up @@ -804,19 +820,71 @@ public Stream GetProcessDefinitionFile()
return OpenStreamByRelativePath(ProcessDefinitionFilePath);
}

/// <summary>
/// Checks if image already exists in wwwroot
/// </summary>
/// <param name="imageFilePath">The file path of the image from wwwroot</param>
/// <returns>A boolean indication if image exists</returns>
public bool DoesImageExist(string imageFilePath)
{
return FileExistsByRelativePath(GetPathToImage(imageFilePath));
}

/// <summary>
/// Gets specified image from App/wwwroot folder of local repo
/// </summary>
/// <param name="imageFilePath">The file path of the image</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The image as stream</returns>
public Stream GetImage(string imageFilePath, CancellationToken cancellationToken = default)
public Stream GetImageAsStreamByFilePath(string imageFilePath, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string imagePath = GetPathToImage(imageFilePath);
return OpenStreamByRelativePath(imagePath);
}

/// <summary>
/// Delete specified image from App/wwwroot folder of local repo
/// </summary>
/// <param name="imageFilePath">The file path of the image</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The image as stream</returns>
public Task DeleteImageByImageFilePath(string imageFilePath, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string imagePath = GetPathToImage(imageFilePath);
DeleteFileByRelativePath(imagePath);
return Task.CompletedTask;
}

/// <summary>
/// Gets all image filePathNames from App/wwwroot folder of local repo
/// </summary>
/// <returns>Array of file paths to all images in App/wwwroot</returns>
public List<string> GetAllImageFileNames()
{
List<string> allFilePaths = new List<string>();
if (!DirectoryExistsByRelativePath(ImagesFolderName))
{
return allFilePaths;
}

// Make sure to sync this list of fileExtensions in frontend if changed until the below issue is done:
// ISSUE: https://github.com/Altinn/altinn-studio/issues/13649
string[] allowedExtensions =
{
".png", ".jpg", ".jpeg", ".svg", ".gif",
".bmp", ".webp", ".tiff", ".ico", ".heif", ".heic"
};

IEnumerable<string> files = GetFilesByRelativeDirectory(ImagesFolderName, "*.*", true)
.Where(file => allowedExtensions.Contains(Path.GetExtension(file).ToLower())).Select(file => Path.GetRelativePath(GetAbsoluteFileOrDirectoryPathSanitized(ImagesFolderName), file));

allFilePaths.AddRange(files);

return allFilePaths;
}

/// <summary>
/// Gets the relative path to a json schema model.
/// </summary>
Expand All @@ -827,11 +895,6 @@ private string GetPathToModelJsonSchema(string modelName)
return Path.Combine(ModelFolderPath, $"{modelName}.schema.json");
}

private string GetPathToModelMetadata(string modelName)
{
return Path.Combine(ModelFolderPath, $"{modelName}.metadata.json");
}

private static string GetPathToTexts()
{
return Path.Combine(ConfigFolderPath, LanguageResourceFolderName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,18 @@ public IEnumerable<string> FindFiles(string[] searchPatterns, bool recursive = t
/// </summary>
/// <param name="relativeDirectory">Relative path to a directory within the repository.</param>
/// <param name="patternMatch">An optional pattern that the retrieved files must match</param>
protected string[] GetFilesByRelativeDirectory(string relativeDirectory, string patternMatch = null)
/// <param name="searchInSubdirectories">An optional parameter to also get files in sub directories</param>
protected string[] GetFilesByRelativeDirectory(string relativeDirectory, string patternMatch = null, bool searchInSubdirectories = false)
{
string absoluteDirectory = GetAbsoluteFileOrDirectoryPathSanitized(relativeDirectory);

Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, absoluteDirectory);
return patternMatch != null ? Directory.GetFiles(absoluteDirectory, patternMatch) : Directory.GetFiles(absoluteDirectory);

SearchOption searchOption = searchInSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;

string searchPatternMatch = patternMatch ?? "*.*";

return Directory.GetFiles(absoluteDirectory, searchPatternMatch, searchOption);
}

/// <summary>
Expand Down
Loading
Loading