Skip to content

Commit

Permalink
Validate external url using backend call
Browse files Browse the repository at this point in the history
  • Loading branch information
standeren committed Aug 13, 2024
1 parent b193e6b commit fe8f3b4
Show file tree
Hide file tree
Showing 19 changed files with 248 additions and 107 deletions.
41 changes: 40 additions & 1 deletion backend/src/Designer/Controllers/ImageController.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Enums;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Services.Interfaces;
using Altinn.Studio.Designer.TypedHttpClients.ImageClient;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -21,14 +24,17 @@ 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>
public ImageController(IImagesService imagesService)
/// <param name="imageClient">A http client to validate external image url</param>
public ImageController(IImagesService imagesService, ImageClient imageClient)
{
_imagesService = imagesService;
_imageClient = imageClient;
}

/// <summary>
Expand Down Expand Up @@ -64,6 +70,39 @@ public ActionResult<List<string>> GetAllImagesFileNames(string org, string repo)
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>204 if an image is fetched, 404 if no response from url, 401 if url requires authentication, 415 if response is not an image</returns>
[HttpGet("validate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)]
public async Task<ImageUrlValidationResult> ValidateExternalImageUrl([FromQuery] string url)
{
var response = await _imageClient.ValidateUrlAsync(url);

if (response == null)
{
return ImageUrlValidationResult.NotValidUrl;
}

if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return ImageUrlValidationResult.Unathorized;
}

var contentType = response.Content.Headers.ContentType.MediaType;
if (!contentType.StartsWith("image/"))
{
return ImageUrlValidationResult.NotAnImage;
}

return ImageUrlValidationResult.Ok;
}

/// <summary>
/// Endpoint for uploading image to application.
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions backend/src/Designer/Enums/ImageUrlValidationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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 = "Unathorized")]
Unathorized,

[EnumMember(Value = "NotValidUrl")]
NotValidUrl
}
2 changes: 2 additions & 0 deletions backend/src/Designer/Infrastructure/ServiceRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Altinn.Studio.Designer.Services.Implementation;
using Altinn.Studio.Designer.Services.Implementation.ProcessModeling;
using Altinn.Studio.Designer.Services.Interfaces;
using Altinn.Studio.Designer.TypedHttpClients.ImageClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -69,6 +70,7 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol
services.AddTransient<IOptionsService, OptionsService>();
services.AddTransient<IEnvironmentsService, EnvironmentsService>();
services.AddHttpClient<IOrgService, OrgService>();
services.AddHttpClient<ImageClient>();
services.AddTransient<IAppDevelopmentService, AppDevelopmentService>();
services.AddTransient<IPreviewService, PreviewService>();
services.AddTransient<IResourceRegistry, ResourceRegistryService>();
Expand Down
45 changes: 45 additions & 0 deletions backend/src/Designer/TypedHttpClients/ImageClient/ImageClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace Altinn.Studio.Designer.TypedHttpClients.ImageClient;

public class ImageClient
{
private readonly HttpClient _httpClient;

public ImageClient(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task<HttpResponseMessage> ValidateUrlAsync(string url)
{
try
{
// Send a HEAD request to the URL to check if the resource exists and fetch the headers
var request = new HttpRequestMessage(HttpMethod.Head, url);

Check warning

Code scanning / CodeQL

Missing Dispose call on local IDisposable Warning

Disposable 'HttpRequestMessage' is created but not disposed.
var response = await _httpClient.SendAsync(request);

// If the response status is not successful, return null
if (!response.IsSuccessStatusCode)
{
return null;
}

return response;
}
catch (UriFormatException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
catch (HttpRequestException ex)

Check warning on line 40 in backend/src/Designer/TypedHttpClients/ImageClient/ImageClient.cs

View workflow job for this annotation

GitHub Actions / Run integration tests against actual gitea and db

The variable 'ex' is declared but never used

Check warning on line 40 in backend/src/Designer/TypedHttpClients/ImageClient/ImageClient.cs

View workflow job for this annotation

GitHub Actions / Run dotnet build and test (ubuntu-latest)

The variable 'ex' is declared but never used

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

This assignment to
ex
is useless, since its value is never read.

Check warning on line 40 in backend/src/Designer/TypedHttpClients/ImageClient/ImageClient.cs

View workflow job for this annotation

GitHub Actions / Run dotnet build and test (windows-latest)

The variable 'ex' is declared but never used
{
return null;
}
}
}
5 changes: 4 additions & 1 deletion frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,7 @@
"ux_editor.pages_error_unique": "Navnet må være unikt.",
"ux_editor.preview": "Forhåndsvisning",
"ux_editor.properties_panel.images.add_image_tab_title": "Legg til bilde",
"ux_editor.properties_panel.images.cancel_image_upload": "Avbryt opplastning",
"ux_editor.properties_panel.images.choose_from_library": "Velg fra biblioteket",
"ux_editor.properties_panel.images.choose_from_library_modal_title": "Velg bilde fra applikasjonens bildebibliotk",
"ux_editor.properties_panel.images.delete_image_options_modal_button_only_ref": "Slett kun bildereferanse",
Expand All @@ -1798,10 +1799,12 @@
"ux_editor.properties_panel.images.invalid_external_url": "Bildeadressen er ikke gyldig. Du må bruke en URL fra åpne nettsider (uten innlogging).",
"ux_editor.properties_panel.images.invalid_external_url_not_an_image": "Bildeadressen er ikke gyldig. URLen må peke på et bilde.",
"ux_editor.properties_panel.images.no_images_in_library": "Ingen bilder er lastet opp til biblioteket enda.",
"ux_editor.properties_panel.images.override_existing_image_button": " Overskriv bildet i biblioteket",
"ux_editor.properties_panel.images.override_existing_image_button": "Erstatt bilde",
"ux_editor.properties_panel.images.override_existing_image_modal_content": "Det eksisterer allerede et bilde i biblioteket med det samme filnavnet. Avbryt for å velge bildet fra biblioteket eller for å laste opp et annet bilde. Eller overskriv bildet i biblioteket med bildet du prøvde å laste opp.",
"ux_editor.properties_panel.images.override_existing_image_modal_title": "Et bilde med dette filnanvet eksisterer allerede i applikasjonens bibliotek",
"ux_editor.properties_panel.images.upload_image": "Last opp eget bilde",
"ux_editor.properties_panel.images.validating_image_url_error": "Kunne ikke validere bildeadresse.",
"ux_editor.properties_panel.images.validating_image_url_pending": "Validerer bildeadresse...",
"ux_editor.properties_panel.options.codelist_switch_to_custom": "Bytt til egendefinert kodeliste",
"ux_editor.properties_panel.options.codelist_switch_to_static": "Bytt til statisk kodeliste",
"ux_editor.properties_panel.options.use_code_list_label": "Bruk kodeliste",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ export type StudioSpinnerProps = {
variant?: SpinnerProps['variant'];
} & HTMLAttributes<HTMLDivElement>;

type ParagraphSize = Exclude<SpinnerProps['size'], '2xs' | 'xxsmall' | 'l' | 'xl' | 'xlarge'>;

export const StudioSpinner = forwardRef<HTMLDivElement, StudioSpinnerProps>(
(
{ spinnerTitle, showSpinnerTitle = false, size = 'medium', variant = 'interaction', ...rest },
ref,
): JSX.Element => {
const spinnerDescriptionId = useId();
const paragraphSize: ParagraphSize =
size === '2xs' || size === 'xxsmall'
? 'xs'
: size === 'xl' || size === 'lg' || size === 'xlarge'
? 'md'
: size;

return (
<div className={classes.spinnerWrapper} ref={ref} {...rest}>
Expand All @@ -27,7 +35,11 @@ export const StudioSpinner = forwardRef<HTMLDivElement, StudioSpinnerProps>(
aria-describedby={showSpinnerTitle ? spinnerDescriptionId : null}
data-testid='studio-spinner-test-id'
/>
{showSpinnerTitle && <Paragraph id={spinnerDescriptionId}>{spinnerTitle}</Paragraph>}
{showSpinnerTitle && (
<Paragraph size={paragraphSize} id={spinnerDescriptionId}>
{spinnerTitle}
</Paragraph>
)}
</div>
);
},
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/shared/src/api/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const userLogoutAfterPath = () => `/Home/Logout`;
export const allImagesPath = (org, app) => `${basePath}/${org}/${app}/images/all`; // Get
export const addImagePath = (org, app) => `${basePath}/${org}/${app}/images`; // Post
export const imagePath = (org, app, imageName) => `${basePath}/${org}/${app}/images/${imageName}`; // Get, Delete
export const validateImageFromExternalUrlPath = (org, app, url) => `${basePath}/${org}/${app}/images/validate?${s({ url })}`; // Get
export const getImageFileNamesPath = (org, app) => `${basePath}/${org}/${app}/images/fileNames`; // Get

// Languages - new text-format
Expand Down
3 changes: 3 additions & 0 deletions frontend/packages/shared/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
repoDiffPath,
getImageFileNamesPath,
imagePath,
validateImageFromExternalUrlPath,
} from './paths';
import type { AppReleasesResponse, DataModelMetadataResponse, SearchRepoFilterParams, SearchRepositoryResponse } from 'app-shared/types/api';
import type { DeploymentsResponse } from 'app-shared/types/api/DeploymentsResponse';
Expand Down Expand Up @@ -81,6 +82,7 @@ import type { AppVersion } from 'app-shared/types/AppVersion';
import type { FormLayoutsResponseV3 } from 'app-shared/types/api/FormLayoutsResponseV3';
import type { Policy } from 'app-shared/types/Policy';
import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse';
import type { ExternalImageUrlValidationResponse } from "app-shared/types/api/ExternalImageUrlValidationResponse";

export const getAppMetadataModelIds = (org: string, app: string, onlyUnReferenced: boolean) => get<string[]>(appMetadataModelIdsPath(org, app, onlyUnReferenced));
export const getAppReleases = (owner: string, app: string) => get<AppReleasesResponse>(releasesPath(owner, app, 'Descending'));
Expand All @@ -99,6 +101,7 @@ export const getFormLayoutsV3 = (owner: string, app: string, layoutSetName: stri
export const getFrontEndSettings = (owner: string, app: string) => get<IFrontEndSettings>(frontEndSettingsPath(owner, app));
export const getImageFileNames = (owner: string, app: string) => get<string[]>(getImageFileNamesPath(owner, app));
export const getImage = (owner: string, app: string, fileName: string) => get<any>(imagePath(owner, app, fileName));
export const validateImageFromExternalUrl = (owner: string, app: string, url: string) => get<ExternalImageUrlValidationResponse>(validateImageFromExternalUrlPath(owner, app, url));
export const getInstanceIdForPreview = (owner: string, app: string) => get<string>(instanceIdForPreviewPath(owner, app));
export const getLayoutNames = (owner: string, app: string) => get<string[]>(layoutNamesPath(owner, app));
export const getLayoutSets = (owner: string, app: string) => get<LayoutSets>(layoutSetsPath(owner, app));
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';
import { useServicesContext } from 'app-shared/contexts/ServicesContext';
import { QueryKey } from 'app-shared/types/QueryKey';
import {ExternalImageUrlValidationResponse} from "app-shared/types/api/ExternalImageUrlValidationResponse";

Check failure on line 5 in frontend/packages/shared/src/hooks/queries/useValidateImageExternalUrlQuery.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

All imports in the declaration are only used as types. Use `import type`

Check failure on line 5 in frontend/packages/shared/src/hooks/queries/useValidateImageExternalUrlQuery.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

A space is required after '{'

Check failure on line 5 in frontend/packages/shared/src/hooks/queries/useValidateImageExternalUrlQuery.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

A space is required before '}'


export const useValidateImageExternalUrlQuery = (
org: string,
app: string,
url: string,
): UseQueryResult<ExternalImageUrlValidationResponse> => {
const { validateImageFromExternalUrl } = useServicesContext();
return useQuery<ExternalImageUrlValidationResponse>({
queryKey: [QueryKey.ImageUrlValidation, org, app, url],
queryFn: () => validateImageFromExternalUrl(org, app, url),
staleTime: 0,
});
};
2 changes: 2 additions & 0 deletions frontend/packages/shared/src/mocks/queriesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
import type { FormLayoutsResponseV3 } from 'app-shared/types/api/FormLayoutsResponseV3';
import type { DeploymentsResponse } from 'app-shared/types/api/DeploymentsResponse';
import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse';
import {ExternalImageUrlValidationResponse} from "app-shared/types/api/ExternalImageUrlValidationResponse";

Check failure on line 72 in frontend/packages/shared/src/mocks/queriesMock.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

All imports in the declaration are only used as types. Use `import type`

Check failure on line 72 in frontend/packages/shared/src/mocks/queriesMock.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

A space is required after '{'

Check failure on line 72 in frontend/packages/shared/src/mocks/queriesMock.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

A space is required before '}'

export const queriesMock: ServicesContextProps = {
// Queries
Expand Down Expand Up @@ -126,6 +127,7 @@ export const queriesMock: ServicesContextProps = {
searchRepos: jest
.fn()
.mockImplementation(() => Promise.resolve<SearchRepositoryResponse>(searchRepositoryResponse)),
validateImageFromExternalUrl: jest.fn().mockImplementation(() => Promise.resolve<ExternalImageUrlValidationResponse>('Ok')),

// Queries - Settings modal
getAppConfig: jest.fn().mockImplementation(() => Promise.resolve<AppConfig>(appConfig)),
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/shared/src/types/QueryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum QueryKey {
FormLayouts = 'FormLayouts',
FrontEndSettings = 'FrontEndSettings',
ImageFileNames = 'ImageFileNames',
ImageUrlValidation = 'ImageUrlValidation',
InstanceId = 'InstanceId',
JsonSchema = 'JsonSchema',
LayoutNames = 'LayoutNames',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type ExternalImageUrlValidationResponse =
| 'Ok'
| 'NotAnImage'
| 'Unauthorized'
| 'NotValidUrl';
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
.missingUrl {
font-style: italic;
}

.validationStatusContainer {
margin: var(--fds-spacing-3);
color: var(--fds-semantic-text-danger-default);
}
Loading

0 comments on commit fe8f3b4

Please sign in to comment.