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")] [BsonElement("td")]
public HashSet<string> Tags { get; set; } public HashSet<string> Tags { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("pt")]
public bool IsProtected { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement("dl")] [BsonElement("dl")]
public bool IsDeleted { get; set; } 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 long FileVersion { get; set; }
public bool IsProtected { get; set; }
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
public AssetMetadata Metadata { 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 string? Slug { get; set; }
public bool? IsProtected { get; set; }
public HashSet<string> Tags { 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); Guard.NotNull(command);
Validate.It(() => "Cannot rename asset.", e => Validate.It(() => "Cannot annotate asset.", e =>
{ {
if (string.IsNullOrWhiteSpace(command.FileName) && if (string.IsNullOrWhiteSpace(command.FileName) &&
string.IsNullOrWhiteSpace(command.Slug) && string.IsNullOrWhiteSpace(command.Slug) &&
command.Tags == null && command.IsProtected == null &&
command.Metadata == 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.FileName),
nameof(command.IsProtected),
nameof(command.Metadata),
nameof(command.Slug), nameof(command.Slug),
nameof(command.Tags), nameof(command.Tags));
nameof(command.Metadata));
} }
}); });
} }

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

@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
string MimeType { get; } string MimeType { get; }
bool IsProtected { get; }
long FileVersion { 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] [DataMember]
public long TotalSize { get; set; } public long TotalSize { get; set; }
[DataMember]
public bool IsProtected { get; set; }
[DataMember] [DataMember]
public HashSet<string> Tags { get; set; } public HashSet<string> Tags { get; set; }
@ -120,6 +123,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
hasChanged = true; hasChanged = true;
} }
if (Is.OptionalChange(IsProtected, e.IsProtected))
{
IsProtected = e.IsProtected.Value;
hasChanged = true;
}
if (Is.OptionalChange(Tags, e.Tags)) if (Is.OptionalChange(Tags, e.Tags))
{ {
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." 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 AddField(new FieldType
{ {
Name = "isImage", 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))] [EventType(nameof(AssetAnnotated))]
public sealed class AssetAnnotated : AssetEvent 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; } 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); 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); 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); writer.WriteValue(s.Value);
break; break;
case JsonNumber s: case JsonNumber s:
if (s.Value % 1 == 0) if (s.Value % 1 == 0)
{ {
writer.WriteValue((long)s.Value); 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) 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); 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.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
#pragma warning disable 1573 #pragma warning disable 1573
@ -64,6 +65,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[HttpGet] [HttpGet]
[Route("assets/{app}/{idOrSlug}/{*more}")] [Route("assets/{app}/{idOrSlug}/{*more}")]
[ProducesResponseType(typeof(FileResult), 200)] [ProducesResponseType(typeof(FileResult), 200)]
[ApiPermission]
[ApiCosts(0.5)] [ApiCosts(0.5)]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetAssetContentBySlug(string app, string idOrSlug, string more, [FromQuery] AssetQuery query) 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] [HttpGet]
[Route("assets/{id}/")] [Route("assets/{id}/")]
[ProducesResponseType(typeof(FileResult), 200)] [ProducesResponseType(typeof(FileResult), 200)]
[ApiPermission]
[ApiCosts(0.5)] [ApiCosts(0.5)]
[AllowAnonymous]
public async Task<IActionResult> GetAssetContent(Guid id, [FromQuery] AssetQuery query) public async Task<IActionResult> GetAssetContent(Guid id, [FromQuery] AssetQuery query)
{ {
var asset = await assetRepository.FindAssetAsync(id); var asset = await assetRepository.FindAssetAsync(id);
@ -111,6 +115,11 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NotFound(); return NotFound();
} }
if (asset.IsProtected && !this.HasPermission(Permissions.AppAssetsRead))
{
return StatusCode(403);
}
var fileVersion = query.Version; var fileVersion = query.Version;
if (fileVersion <= EtagVersion.Any) 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> /// </summary>
public string? Slug { get; set; } public string? Slug { get; set; }
/// <summary>
/// True, when the asset is not public.
/// </summary>
public bool? IsProtected { get; set; }
/// <summary> /// <summary>
/// The new asset tags. /// The new asset tags.
/// </summary> /// </summary>

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

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

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using IdentityModel.AspNetCore.OAuth2Introspection;
using IdentityServer4.AccessTokenValidation; using IdentityServer4.AccessTokenValidation;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@ -44,6 +45,16 @@ namespace Squidex.Config.Authentication
options.ApiSecret = null; options.ApiSecret = null;
options.RequireHttpsMetadata = identityOptions.RequiresHttps; options.RequireHttpsMetadata = identityOptions.RequiresHttps;
options.SupportedTokens = SupportedTokens.Jwt; 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 => 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] [Fact]
public async Task AnnotateMetadata_should_create_events_and_update_state() 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(); var command = new AnnotateAsset();
ValidationAssert.Throws(() => GuardAsset.CanAnnotate(command), 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] [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 fileSize
fileVersion fileVersion
isImage isImage
isProtected
pixelWidth pixelWidth
pixelHeight pixelHeight
type type
@ -188,6 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize = 1024, fileSize = 1024,
fileVersion = 123, fileVersion = 123,
isImage = true, isImage = true,
isProtected = false,
pixelWidth = 800, pixelWidth = 800,
pixelHeight = 600, pixelHeight = 600,
type = "IMAGE", type = "IMAGE",
@ -236,6 +238,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize fileSize
fileVersion fileVersion
isImage isImage
isProtected
pixelWidth pixelWidth
pixelHeight pixelHeight
type type
@ -282,6 +285,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
fileSize = 1024, fileSize = 1024,
fileVersion = 123, fileVersion = 123,
isImage = true, isImage = true,
isProtected = false,
pixelWidth = 800, pixelWidth = 800,
pixelHeight = 600, pixelHeight = 600,
type = "IMAGE", type = "IMAGE",

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

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

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

@ -68,6 +68,12 @@
</button> </button>
</div> </div>
</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>
<ng-container footer> <ng-container footer>

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

@ -11,7 +11,7 @@
<ng-container *ngIf="asset.canPreview; else noPreview"> <ng-container *ngIf="asset.canPreview; else noPreview">
<div class="file-image"> <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> </div>
</ng-container> </ng-container>
<ng-template #noPreview> <ng-template #noPreview>

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

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

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

@ -416,6 +416,7 @@ describe('AssetsService', () => {
fileType: 'png', fileType: 'png',
fileSize: id * 2, fileSize: id * 2,
fileVersion: id * 4, fileVersion: id * 4,
isProtected: true,
parentId, parentId,
mimeType: 'image/png', mimeType: 'image/png',
type: `my-type${id}${suffix}`, type: `my-type${id}${suffix}`,
@ -472,6 +473,7 @@ export function createAsset(id: number, tags?: ReadonlyArray<string>, suffix = '
'png', 'png',
id * 2, id * 2,
id * 4, id * 4,
true,
parentId, parentId,
'image/png', 'image/png',
`my-type${id}${suffix}`, `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 fileType: string,
public readonly fileSize: number, public readonly fileSize: number,
public readonly fileVersion: number, public readonly fileVersion: number,
public readonly isProtected: boolean,
public readonly parentId: string, public readonly parentId: string,
public readonly mimeType: string, public readonly mimeType: string,
public readonly type: string, public readonly type: string,
@ -122,6 +123,7 @@ export class AssetFolderDto {
export interface AnnotateAssetDto { export interface AnnotateAssetDto {
readonly fileName?: string; readonly fileName?: string;
readonly isProtected?: boolean;
readonly slug?: string; readonly slug?: string;
readonly tags?: ReadonlyArray<string>; readonly tags?: ReadonlyArray<string>;
readonly metadata?: { [key: string]: any }; readonly metadata?: { [key: string]: any };
@ -377,6 +379,7 @@ function parseAsset(response: any) {
response.fileType, response.fileType,
response.fileSize, response.fileSize,
response.fileVersion, response.fileVersion,
response.isProtected,
response.parentId, response.parentId,
response.mimeType, response.mimeType,
response.type, response.type,

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

@ -39,6 +39,10 @@ export class Profile {
return this.user.expired || false; return this.user.expired || false;
} }
public get accessKey(): string {
return this.user.access_token;
}
public get authToken(): string { public get authToken(): string {
return `${this.user!.token_type} ${this.user.access_token}`; 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', 'Tag1',
'Tag2' 'Tag2'
], ],
isProtected: false,
metadata: { metadata: {
key1: null, key1: null,
key2: 'String', 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 private readonly formBuilder: FormBuilder
) { ) {
super(formBuilder.group({ super(formBuilder.group({
isProtected: false,
fileName: ['', fileName: ['',
[ [
Validators.required Validators.required
@ -41,7 +42,11 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
Validators.required Validators.required
] ]
], ],
tags: [[]], tags: [[],
[
Validators.required
]
],
metadata: formBuilder.array([]) metadata: formBuilder.array([])
})); }));
} }
@ -106,6 +111,10 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
delete result.slug; delete result.slug;
} }
if (result.isProtected === asset.isProtected) {
delete result.isProtected;
}
if (Types.jsJsonEquals(result.metadata, asset.metadata)) { if (Types.jsJsonEquals(result.metadata, asset.metadata)) {
delete result.metadata; delete result.metadata;
} }

Loading…
Cancel
Save