Browse Source

Protected assets. (#470)

* Protected assets.
pull/473/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
4455baf848
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  2. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs
  3. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AnnotateAsset.cs
  4. 14
      backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs
  6. 10
      backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs
  7. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs
  8. 6
      backend/src/Squidex.Domain.Apps.Events/Assets/AssetAnnotated.cs
  9. 7
      backend/src/Squidex.Infrastructure/Commands/Is.cs
  10. 1
      backend/src/Squidex.Infrastructure/Json/Newtonsoft/JsonValueConverter.cs
  11. 4
      backend/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs
  12. 9
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  13. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AnnotateAssetDto.cs
  14. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  15. 11
      backend/src/Squidex/Config/Authentication/IdentityServerServices.cs
  16. 19
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs
  17. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs
  18. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  19. 7
      frontend/app/framework/angular/image-source.directive.ts
  20. 6
      frontend/app/shared/components/asset-dialog.component.html
  21. 2
      frontend/app/shared/components/asset.component.html
  22. 2
      frontend/app/shared/components/asset.component.ts
  23. 2
      frontend/app/shared/services/assets.service.spec.ts
  24. 3
      frontend/app/shared/services/assets.service.ts
  25. 4
      frontend/app/shared/services/auth.service.ts
  26. 1
      frontend/app/shared/state/assets.forms.spec.ts
  27. 11
      frontend/app/shared/state/assets.forms.ts

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -89,6 +89,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
[BsonElement("td")]
public HashSet<string> Tags { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("pt")]
public bool IsProtected { get; set; }
[BsonRequired]
[BsonElement("dl")]
public bool IsDeleted { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs

@ -49,6 +49,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
public long FileVersion { get; set; }
public bool IsProtected { get; set; }
public bool IsDeleted { get; set; }
public AssetMetadata Metadata { get; set; }

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AnnotateAsset.cs

@ -16,8 +16,10 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
public string? Slug { get; set; }
public bool? IsProtected { get; set; }
public HashSet<string> Tags { get; set; }
public AssetMetadata Metadata { get; set; }
public AssetMetadata? Metadata { get; set; }
}
}

14
backend/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs

@ -19,18 +19,20 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards
{
Guard.NotNull(command);
Validate.It(() => "Cannot rename asset.", e =>
Validate.It(() => "Cannot annotate asset.", e =>
{
if (string.IsNullOrWhiteSpace(command.FileName) &&
string.IsNullOrWhiteSpace(command.Slug) &&
command.Tags == null &&
command.Metadata == null)
command.IsProtected == null &&
command.Metadata == null &&
command.Tags == null)
{
e("Either file name, slug, tags or metadata must be defined.",
e("At least one property must be defined.",
nameof(command.FileName),
nameof(command.IsProtected),
nameof(command.Metadata),
nameof(command.Slug),
nameof(command.Tags),
nameof(command.Metadata));
nameof(command.Tags));
}
});
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs

@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
string MimeType { get; }
bool IsProtected { get; }
long FileVersion { get; }
}
}

10
backend/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs

@ -48,6 +48,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
[DataMember]
public long TotalSize { get; set; }
[DataMember]
public bool IsProtected { get; set; }
[DataMember]
public HashSet<string> Tags { get; set; }
@ -120,6 +123,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
hasChanged = true;
}
if (Is.OptionalChange(IsProtected, e.IsProtected))
{
IsProtected = e.IsProtected.Value;
hasChanged = true;
}
if (Is.OptionalChange(Tags, e.Tags))
{
Tags = e.Tags;

8
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs

@ -140,6 +140,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = "The file name as slug."
});
AddField(new FieldType
{
Name = "isProtected",
ResolvedType = AllTypes.NonNullBoolean,
Resolver = Resolve(x => x.IsProtected),
Description = "True, when the asset is not public."
});
AddField(new FieldType
{
Name = "isImage",

6
backend/src/Squidex.Domain.Apps.Events/Assets/AssetAnnotated.cs

@ -14,9 +14,11 @@ namespace Squidex.Domain.Apps.Events.Assets
[EventType(nameof(AssetAnnotated))]
public sealed class AssetAnnotated : AssetEvent
{
public string FileName { get; set; }
public string? FileName { get; set; }
public string Slug { get; set; }
public string? Slug { get; set; }
public bool? IsProtected { get; set; }
public AssetMetadata? Metadata { get; set; }

7
backend/src/Squidex.Infrastructure/Commands/Is.cs

@ -23,7 +23,12 @@ namespace Squidex.Infrastructure.Commands
return !Equals(oldValue, newValue);
}
public static bool OptionalChange(string oldValue, string? newValue)
public static bool OptionalChange(bool oldValue, [NotNullWhen(true)] bool? newValue)
{
return newValue.HasValue && oldValue != newValue.Value;
}
public static bool OptionalChange(string oldValue, [NotNullWhen(true)] string? newValue)
{
return !string.IsNullOrWhiteSpace(newValue) && !string.Equals(oldValue, newValue);
}

1
backend/src/Squidex.Infrastructure/Json/Newtonsoft/JsonValueConverter.cs

@ -142,7 +142,6 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
writer.WriteValue(s.Value);
break;
case JsonNumber s:
if (s.Value % 1 == 0)
{
writer.WriteValue((long)s.Value);

4
backend/src/Squidex.Infrastructure/Reflection/SimpleMapper.cs

@ -149,8 +149,10 @@ namespace Squidex.Infrastructure.Reflection
public static TTarget MapClass(TSource source, TTarget destination, CultureInfo culture)
{
foreach (var mapper in Mappers)
for (var i = 0; i < Mappers.Count; i++)
{
var mapper = Mappers[i];
mapper.MapProperty(source, destination, culture);
}

9
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -19,6 +19,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log;
using Squidex.Shared;
using Squidex.Web;
#pragma warning disable 1573
@ -64,6 +65,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[HttpGet]
[Route("assets/{app}/{idOrSlug}/{*more}")]
[ProducesResponseType(typeof(FileResult), 200)]
[ApiPermission]
[ApiCosts(0.5)]
[AllowAnonymous]
public async Task<IActionResult> GetAssetContentBySlug(string app, string idOrSlug, string more, [FromQuery] AssetQuery query)
@ -94,7 +96,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[HttpGet]
[Route("assets/{id}/")]
[ProducesResponseType(typeof(FileResult), 200)]
[ApiPermission]
[ApiCosts(0.5)]
[AllowAnonymous]
public async Task<IActionResult> GetAssetContent(Guid id, [FromQuery] AssetQuery query)
{
var asset = await assetRepository.FindAssetAsync(id);
@ -111,6 +115,11 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NotFound();
}
if (asset.IsProtected && !this.HasPermission(Permissions.AppAssetsRead))
{
return StatusCode(403);
}
var fileVersion = query.Version;
if (fileVersion <= EtagVersion.Any)

5
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AnnotateAssetDto.cs

@ -25,6 +25,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public string? Slug { get; set; }
/// <summary>
/// True, when the asset is not public.
/// </summary>
public bool? IsProtected { get; set; }
/// <summary>
/// The new asset tags.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs

@ -42,6 +42,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public string? FileHash { get; set; }
/// <summary>
/// True, when the asset is not public.
/// </summary>
public bool IsProtected { get; set; }
/// <summary>
/// The slug.
/// </summary>

11
backend/src/Squidex/Config/Authentication/IdentityServerServices.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using IdentityModel.AspNetCore.OAuth2Introspection;
using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@ -44,6 +45,16 @@ namespace Squidex.Config.Authentication
options.ApiSecret = null;
options.RequireHttpsMetadata = identityOptions.RequiresHttps;
options.SupportedTokens = SupportedTokens.Jwt;
var fromHeader = TokenRetrieval.FromAuthorizationHeader();
var fromQuery = TokenRetrieval.FromQueryString();
options.TokenRetriever = request =>
{
var result = fromHeader(request) ?? fromQuery(request);
return result;
};
});
authBuilder.AddOpenIdConnect(options =>

19
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs

@ -162,6 +162,25 @@ namespace Squidex.Domain.Apps.Entities.Assets
);
}
[Fact]
public async Task AnnotateProtected_should_create_events_and_update_state()
{
var command = new AnnotateAsset { IsProtected = true };
await ExecuteCreateAsync();
var result = await PublishIdempotentAsync(command);
result.ShouldBeEquivalent2(sut.Snapshot);
Assert.Equal(command.IsProtected, sut.Snapshot.IsProtected);
LastEvents
.ShouldHaveSameEvents(
CreateAssetEvent(new AssetAnnotated { IsProtected = command.IsProtected })
);
}
[Fact]
public async Task AnnotateMetadata_should_create_events_and_update_state()
{

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs

@ -96,7 +96,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards
var command = new AnnotateAsset();
ValidationAssert.Throws(() => GuardAsset.CanAnnotate(command),
new ValidationError("Either file name, slug, tags or metadata must be defined.", "FileName", "Slug", "Tags", "Metadata"));
new ValidationError("At least one property must be defined.", "FileName", "IsProtected", "Metadata", "Slug", "Tags"));
}
[Fact]

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -146,6 +146,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize
fileVersion
isImage
isProtected
pixelWidth
pixelHeight
type
@ -188,6 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize = 1024,
fileVersion = 123,
isImage = true,
isProtected = false,
pixelWidth = 800,
pixelHeight = 600,
type = "IMAGE",
@ -236,6 +238,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize
fileVersion
isImage
isProtected
pixelWidth
pixelHeight
type
@ -282,6 +285,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize = 1024,
fileVersion = 123,
isImage = true,
isProtected = false,
pixelWidth = 800,
pixelHeight = 600,
type = "IMAGE",

7
frontend/app/framework/angular/image-source.directive.ts

@ -26,6 +26,9 @@ export class ImageSourceDirective extends ResourceOwner implements OnChanges, On
@Input()
public retryCount = 10;
@Input()
public accessToken: string;
@Input()
public layoutKey: string;
@ -129,6 +132,10 @@ export class ImageSourceDirective extends ResourceOwner implements OnChanges, On
source += `&q=${this.loadQuery}`;
}
if (this.accessToken) {
source += `&access_token=${this.accessToken}`;
}
this.renderer.setProperty(this.element.nativeElement, 'src', source);
}
}

6
frontend/app/shared/components/asset-dialog.component.html

@ -68,6 +68,12 @@
</button>
</div>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="isProtected" formControlName="isProtected" />
<label class="form-check-label" for="isProtected">Protected</label>
</div>
</ng-container>
<ng-container footer>

2
frontend/app/shared/components/asset.component.html

@ -11,7 +11,7 @@
<ng-container *ngIf="asset.canPreview; else noPreview">
<div class="file-image">
<img [sqxImageSource]="asset | sqxAssetPreviewUrl" class="bg" layoutKey="asset-large">
<img [sqxImageSource]="asset | sqxAssetPreviewUrl" [accessToken]="authService.user?.accessKey" class="bg" layoutKey="asset-large">
</div>
</ng-container>
<ng-template #noPreview>

2
frontend/app/shared/components/asset.component.ts

@ -11,6 +11,7 @@ import {
AssetDto,
AssetsState,
AssetUploaderState,
AuthService,
DialogModel,
DialogService,
Types,
@ -74,6 +75,7 @@ export class AssetComponent implements OnInit {
public editDialog = new DialogModel();
constructor(
public readonly authService: AuthService,
private readonly assetUploader: AssetUploaderState,
private readonly changeDetector: ChangeDetectorRef,
private readonly dialogs: DialogService

2
frontend/app/shared/services/assets.service.spec.ts

@ -416,6 +416,7 @@ describe('AssetsService', () => {
fileType: 'png',
fileSize: id * 2,
fileVersion: id * 4,
isProtected: true,
parentId,
mimeType: 'image/png',
type: `my-type${id}${suffix}`,
@ -472,6 +473,7 @@ export function createAsset(id: number, tags?: ReadonlyArray<string>, suffix = '
'png',
id * 2,
id * 4,
true,
parentId,
'image/png',
`my-type${id}${suffix}`,

3
frontend/app/shared/services/assets.service.ts

@ -67,6 +67,7 @@ export class AssetDto {
public readonly fileType: string,
public readonly fileSize: number,
public readonly fileVersion: number,
public readonly isProtected: boolean,
public readonly parentId: string,
public readonly mimeType: string,
public readonly type: string,
@ -122,6 +123,7 @@ export class AssetFolderDto {
export interface AnnotateAssetDto {
readonly fileName?: string;
readonly isProtected?: boolean;
readonly slug?: string;
readonly tags?: ReadonlyArray<string>;
readonly metadata?: { [key: string]: any };
@ -377,6 +379,7 @@ function parseAsset(response: any) {
response.fileType,
response.fileSize,
response.fileVersion,
response.isProtected,
response.parentId,
response.mimeType,
response.type,

4
frontend/app/shared/services/auth.service.ts

@ -39,6 +39,10 @@ export class Profile {
return this.user.expired || false;
}
public get accessKey(): string {
return this.user.access_token;
}
public get authToken(): string {
return `${this.user!.token_type} ${this.user.access_token}`;
}

1
frontend/app/shared/state/assets.forms.spec.ts

@ -18,6 +18,7 @@ describe('AnnotateAssetForm', () => {
'Tag1',
'Tag2'
],
isProtected: false,
metadata: {
key1: null,
key2: 'String',

11
frontend/app/shared/state/assets.forms.ts

@ -31,6 +31,7 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
private readonly formBuilder: FormBuilder
) {
super(formBuilder.group({
isProtected: false,
fileName: ['',
[
Validators.required
@ -41,7 +42,11 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
Validators.required
]
],
tags: [[]],
tags: [[],
[
Validators.required
]
],
metadata: formBuilder.array([])
}));
}
@ -106,6 +111,10 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
delete result.slug;
}
if (result.isProtected === asset.isProtected) {
delete result.isProtected;
}
if (Types.jsJsonEquals(result.metadata, asset.metadata)) {
delete result.metadata;
}

Loading…
Cancel
Save