Browse Source

Embedding (#833)

* Simple SDK.

* Fixes.

* More progress with embedding.

* Check extension.

* Fix tests.

* Fix code.

* Open api tests and many fixes.

* More fixes.

* Final fixes.

* Optional tag restoring.

* Fix backup tests.
pull/837/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
de60af0bbb
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/extensions/Squidex.Extensions/Samples/Controllers/PluginController.cs
  2. 3
      backend/src/Squidex.Domain.Apps.Core.Operations/FieldDescriptions.cs
  3. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs
  6. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs
  7. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs
  9. 33
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  11. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs
  12. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
  13. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  15. 53
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs
  16. 2
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  17. 14
      backend/src/Squidex.Web/ApiController.cs
  18. 22
      backend/src/Squidex.Web/Constants.cs
  19. 144
      backend/src/Squidex.Web/Pipeline/SameSiteCookiesServiceCollectionExtensions.cs
  20. 9
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  21. 2
      backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs
  22. 2
      backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs
  23. 2
      backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs
  24. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  25. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  26. 2
      backend/src/Squidex/Areas/Api/Controllers/Docs/DocsController.cs
  27. 2
      backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  28. 36
      backend/src/Squidex/Areas/Api/Startup.cs
  29. 37
      backend/src/Squidex/Areas/Frontend/Middlewares/EmbedMiddleware.cs
  30. 25
      backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs
  31. 2
      backend/src/Squidex/Areas/Frontend/Middlewares/NotifoMiddleware.cs
  32. 9
      backend/src/Squidex/Areas/Frontend/Middlewares/OptionsFeature.cs
  33. 70
      backend/src/Squidex/Areas/Frontend/Startup.cs
  34. 29
      backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs
  35. 13
      backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs
  36. 24
      backend/src/Squidex/Areas/IdentityServer/Controllers/Info/InfoController.cs
  37. 20
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  38. 5
      backend/src/Squidex/Areas/IdentityServer/Controllers/UserInfo/UserInfoController.cs
  39. 41
      backend/src/Squidex/Areas/IdentityServer/Startup.cs
  40. 28
      backend/src/Squidex/Areas/OrleansDashboard/Startup.cs
  41. 27
      backend/src/Squidex/Areas/Portal/Startup.cs
  42. 12
      backend/src/Squidex/Config/Authentication/AuthenticationServices.cs
  43. 1
      backend/src/Squidex/Config/Domain/AppsServices.cs
  44. 3
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  45. 3
      backend/src/Squidex/Config/Web/WebExtensions.cs
  46. 2
      backend/src/Squidex/Squidex.csproj
  47. 63
      backend/src/Squidex/Startup.cs
  48. 76
      backend/src/Squidex/wwwroot/scripts/embed-sample.html
  49. 1
      backend/src/Squidex/wwwroot/scripts/embed-sdk.css
  50. 2
      backend/src/Squidex/wwwroot/scripts/embed-sdk.js
  51. 1
      backend/src/Squidex/wwwroot/scripts/embed-sdk.js.map
  52. 17
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsSearchSourceTests.cs
  53. 38
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs
  54. 38
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs
  55. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestAsset.cs
  56. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  57. 84
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/CalculateTokensTests.cs
  58. 52
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs
  59. 7
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
  60. 35
      backend/tools/TestSuite/TestSuite.ApiTests/OpenApiTests.cs
  61. 1
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  62. 2
      backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
  63. 9
      frontend/src/app/app.module.ts
  64. 1
      frontend/src/app/features/assets/pages/assets-page.component.ts
  65. 2
      frontend/src/app/features/content/pages/schemas/schemas-page.component.html
  66. 7
      frontend/src/app/features/content/pages/schemas/schemas-page.component.ts
  67. 18
      frontend/src/app/shared/services/assets.service.spec.ts
  68. 36
      frontend/src/app/shared/services/assets.service.ts
  69. 15
      frontend/src/app/shared/state/assets.state.spec.ts
  70. 10
      frontend/src/app/shared/state/assets.state.ts
  71. 4
      frontend/src/app/shell/pages/internal/internal-area.component.html
  72. 20
      frontend/src/app/shell/pages/internal/internal-area.component.scss
  73. 8
      frontend/src/app/shell/pages/internal/internal-area.component.ts
  74. 4
      frontend/src/index.html
  75. 3
      sdk/.gitignore
  76. 19
      sdk/README.md
  77. 37655
      sdk/package-lock.json
  78. 42
      sdk/package.json
  79. 1
      sdk/size-plugin.json
  80. 5
      sdk/src/.babelrc
  81. 24
      sdk/src/components/iframe.tsx
  82. 230
      sdk/src/components/overlay-container.tsx
  83. 147
      sdk/src/components/overlay.tsx
  84. 13
      sdk/src/components/shared.ts
  85. 37
      sdk/src/index.ts
  86. 21
      sdk/src/manifest.json
  87. 19
      sdk/src/render.tsx
  88. 125
      sdk/src/style/index.scss
  89. 99
      sdk/src/template.html
  90. 52
      sdk/src/utils.ts
  91. 16
      sdk/tsconfig.json

2
backend/extensions/Squidex.Extensions/Samples/Controllers/PluginController.cs

@ -18,7 +18,7 @@ namespace Squidex.Extensions.Samples.Controllers
{
}
[Route("/plugins/sample")]
[Route("plugins/sample")]
public IActionResult Test()
{
return Ok(new { text = "I am Plugin" });

3
backend/src/Squidex.Domain.Apps.Core.Operations/FieldDescriptions.cs

@ -153,6 +153,9 @@ namespace Squidex.Domain.Apps.Core
public static string Context =>
"The context object holding all values.";
public static string EditToken =>
"The edit token.";
public static string EntityCreated =>
"The timestamp when the object was created.";

4
backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Core
string? AssetThumbnail(NamedId<DomainId> appId, string idOrSlug, AssetType assetType);
string AssetsUI(NamedId<DomainId> appId, string? query = null);
string AssetsUI(NamedId<DomainId> appId, string? @ref = null);
string AssetContent(NamedId<DomainId> appId, string idOrSlug);
@ -54,6 +54,8 @@ namespace Squidex.Domain.Apps.Core
string WorkflowsUI(NamedId<DomainId> appId);
string Root();
string UI();
}
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs

@ -6,8 +6,6 @@
// ==========================================================================
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Squidex.Text;
namespace Squidex.Domain.Apps.Core.Scripting

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

@ -43,6 +43,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
public string MetadataText { get; set; }
public string? EditToken { get; set; }
public long FileSize { get; set; }
public long FileVersion { get; set; }

4
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs

@ -40,10 +40,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
if (assets.Count > 0)
{
var url = urlGenerator.AssetsUI(context.App.NamedId(), query);
foreach (var asset in assets)
{
var url = urlGenerator.AssetsUI(context.App.NamedId(), asset.Id.ToString());
result.Add(asset.FileName, SearchResultType.Asset, url);
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs

@ -116,7 +116,12 @@ namespace Squidex.Domain.Apps.Entities.Assets
private async Task RestoreTagsAsync(RestoreContext context,
CancellationToken ct)
{
var tags = await context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(TagsFile, ct);
var tags = (Dictionary<string, Tag>?)null;
if (await context.Reader.HasFileAsync(TagsFile, ct))
{
tags = await context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(TagsFile, ct);
}
var alias = (Dictionary<string, string>?)null;
@ -125,6 +130,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
alias = await context.Reader.ReadJsonAsync<Dictionary<string, string>>(TagsAliasFile, ct);
}
if (alias == null && tags == null)
{
return;
}
var export = new TagsExport { Tags = tags, Alias = alias };
await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, export);

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

@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
HashSet<string> TagNames { get; }
string MetadataText { get; }
string? EditToken { get; set; }
}
}

33
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs

@ -6,9 +6,11 @@
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets.Queries
@ -18,12 +20,17 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
private readonly ITagService tagService;
private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources;
private readonly IRequestCache requestCache;
private readonly IUrlGenerator urlGenerator;
private readonly IJsonSerializer jsonSerializer;
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache)
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache,
IUrlGenerator urlGenerator, IJsonSerializer jsonSerializer)
{
this.tagService = tagService;
this.assetMetadataSources = assetMetadataSources;
this.requestCache = requestCache;
this.urlGenerator = urlGenerator;
this.jsonSerializer = jsonSerializer;
}
public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset, Context context,
@ -57,12 +64,36 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
await EnrichTagsAsync(results, ct);
EnrichWithMetadataText(results);
if (!context.IsFrontendClient)
{
ComputeTokens(results);
}
}
return results;
}
}
private void ComputeTokens(IEnumerable<IEnrichedAssetEntity> assets)
{
var url = urlGenerator.Root();
foreach (var asset in assets)
{
var token = new
{
a = asset.AppId.Name,
i = asset.Id.ToString(),
u = url
};
var json = jsonSerializer.Serialize(token);
asset.EditToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
}
private void EnrichWithMetadataText(List<AssetEntity> results)
{
var sb = new StringBuilder();

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -56,6 +56,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
public string? ScheduledStatusColor { get; set; }
public string? EditToken { get; set; }
public RootField[]? ReferenceFields { get; set; }
public DomainId UniqueId

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

@ -227,6 +227,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
Description = FieldDescriptions.AssetMetadata
});
AddField(new FieldType
{
Name = "editToken",
ResolvedType = AllTypes.String,
Resolver = Resolve(x => x.EditToken),
Description = FieldDescriptions.EditToken
});
if (canGenerateSourceUrl)
{
AddField(new FieldType

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

@ -128,6 +128,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
Description = FieldDescriptions.ContentUrl
};
public static readonly FieldType EditToken = new FieldType
{
Name = "editToken",
ResolvedType = AllTypes.String,
Resolver = Resolve(x => x.EditToken),
Description = FieldDescriptions.EditToken
};
private static IFieldResolver Resolve<T>(Func<JsonObject, T> resolver)
{
return Resolvers.Sync(resolver);

1
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs

@ -38,6 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
AddField(ContentFields.Created);
AddField(ContentFields.CreatedBy);
AddField(ContentFields.CreatedByUser);
AddField(ContentFields.EditToken);
AddField(ContentFields.LastModified);
AddField(ContentFields.LastModifiedBy);
AddField(ContentFields.LastModifiedByUser);

2
backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs

@ -24,6 +24,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
string SchemaDisplayName { get; }
string? EditToken { get; }
RootField[]? ReferenceFields { get; }
StatusInfo[]? NextStatuses { get; }

53
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs

@ -0,0 +1,53 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure.Json;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
public sealed class CalculateTokens : IContentEnricherStep
{
private readonly IJsonSerializer jsonSerializer;
private readonly IUrlGenerator urlGenerator;
public CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer jsonSerializer)
{
this.jsonSerializer = jsonSerializer;
this.urlGenerator = urlGenerator;
}
public Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas,
CancellationToken ct)
{
if (context.IsFrontendClient)
{
return Task.CompletedTask;
}
var url = urlGenerator.Root();
foreach (var content in contents)
{
var token = new
{
a = content.AppId.Name,
s = content.SchemaId.Name,
i = content.Id.ToString(),
u = url
};
var json = jsonSerializer.Serialize(token);
content.EditToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
return Task.CompletedTask;
}
}
}

2
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -33,7 +33,7 @@
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="2.6.0" />
<PackageReference Include="Squidex.Caching" Version="1.8.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="2.8.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="2.13.0" />
<PackageReference Include="Squidex.Log" Version="1.6.0" />
<PackageReference Include="Squidex.Text" Version="1.7.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

14
backend/src/Squidex.Web/ApiController.cs

@ -6,7 +6,6 @@
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
@ -15,10 +14,11 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Web
{
[Area("Api")]
[Area("api")]
[ApiController]
[ApiExceptionFilter]
[ApiModelValidation(false)]
[Route(Constants.PrefixApi)]
public abstract class ApiController : Controller
{
private readonly Lazy<Resources> resources;
@ -76,15 +76,5 @@ namespace Squidex.Web
resources = new Lazy<Resources>(() => new Resources(this));
}
public override void OnActionExecuting(ActionExecutingContext context)
{
var request = context.HttpContext.Request;
if (!request.PathBase.HasValue || request.PathBase.Value?.EndsWith("/api", StringComparison.OrdinalIgnoreCase) != true)
{
context.Result = new RedirectResult("/");
}
}
}
}

22
backend/src/Squidex.Web/Constants.cs

@ -12,27 +12,27 @@ namespace Squidex.Web
{
public static class Constants
{
public static readonly string SecurityDefinition = "squidex-oauth-auth";
public const string SecurityDefinition = "squidex-oauth-auth";
public static readonly string OrleansClusterId = "squidex-v2";
public const string OrleansClusterId = "squidex-v2";
public static readonly string ApiSecurityScheme = "API";
public const string ApiSecurityScheme = "API";
public static readonly string PrefixApi = "/api";
public const string PrefixApi = "/api";
public static readonly string PrefixOrleans = "/orleans";
public const string PrefixOrleans = "/orleans";
public static readonly string PrefixPortal = "/portal";
public const string PrefixPortal = "/portal";
public static readonly string PrefixIdentityServer = "/identity-server";
public const string PrefixIdentityServer = "/identity-server";
public static readonly string ScopePermissions = "permissions";
public const string ScopePermissions = "permissions";
public static readonly string ScopeProfile = "squidex-profile";
public const string ScopeProfile = "squidex-profile";
public static readonly string ScopeRole = "role";
public const string ScopeRole = "role";
public static readonly string ScopeApi = "squidex-api";
public const string ScopeApi = "squidex-api";
public static readonly string ClientFrontendId = DefaultClients.Frontend;

144
backend/src/Squidex.Web/Pipeline/SameSiteCookiesServiceCollectionExtensions.cs

@ -1,144 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Squidex.Web.Pipeline
{
public static class SameSiteCookiesServiceCollectionExtensions
{
/// <summary>
/// -1 defines the unspecified value, which tells ASPNET Core to NOT
/// send the SameSite attribute. With ASPNET Core 3.1 the
/// <seealso cref="SameSiteMode" /> enum will have a definition for
/// Unspecified.
/// </summary>
private const SameSiteMode Unspecified = (SameSiteMode)(-1);
/// <summary>
/// Configures a cookie policy to properly set the SameSite attribute
/// for Browsers that handle unknown values as Strict. Ensure that you
/// add the <seealso cref="Microsoft.AspNetCore.CookiePolicy.CookiePolicyMiddleware" />
/// into the pipeline before sending any cookies!.
/// </summary>
/// <remarks>
/// Minimum ASPNET Core Version required for this code:
/// - 2.1.14
/// - 2.2.8
/// - 3.0.1
/// - 3.1.0-preview1
/// Starting with version 80 of Chrome (to be released in February 2020)
/// cookies with NO SameSite attribute are treated as SameSite=Lax.
/// In order to always get the cookies send they need to be set to
/// SameSite=None. But since the current standard only defines Lax and
/// Strict as valid values there are some browsers that treat invalid
/// values as SameSite=Strict. We therefore need to check the browser
/// and either send SameSite=None or prevent the sending of SameSite=None.
/// Relevant links:
/// - https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1
/// - https://tools.ietf.org/html/draft-west-cookie-incrementalism-00
/// - https://www.chromium.org/updates/same-site
/// - https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/
/// - https://bugs.webkit.org/show_bug.cgi?id=198181.
/// </remarks>
/// <param name="services">The service collection to register <see cref="CookiePolicyOptions" /> into.</param>
/// <returns>The modified <see cref="IServiceCollection" />.</returns>
public static IServiceCollection AddNonBreakingSameSiteCookies(this IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = Unspecified;
options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
});
return services;
}
private static void CheckSameSite(HttpContext httpContext, CookieOptions options)
{
if (options.SameSite == SameSiteMode.None)
{
var userAgent = httpContext.Request.Headers["User-Agent"].ToString();
if (DisallowsSameSiteNone(userAgent))
{
options.SameSite = Unspecified;
}
}
}
/// <summary>
/// Checks if the UserAgent is known to interpret an unknown value as Strict.
/// For those the <see cref="CookieOptions.SameSite" /> property should be
/// set to <see cref="Unspecified" />.
/// </summary>
/// <remarks>
/// This code is taken from Microsoft:
/// https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/.
/// </remarks>
/// <param name="userAgent">The user agent string to check.</param>
/// <returns>Whether the specified user agent (browser) accepts SameSite=None or not.</returns>
private static bool DisallowsSameSiteNone(string userAgent)
{
// Cover all iOS based browsers here.
// This includes:
// - Safari on iOS 12 for iPhone, iPod Touch, iPad
// - WkWebview on iOS 12 for iPhone, iPod Touch, iPad
// - Chrome on iOS 12 for iPhone, iPod Touch, iPad
// All of which are broken by SameSite=None, because they use the
// iOS networking stack.
// Notes from Thinktecture:
// Regarding https://caniuse.com/#search=samesite iOS versions lower
// than 12 are not supporting SameSite at all. Starting with version 13
// unknown values are NOT treated as strict anymore. Therefore we only
// need to check version 12.
if (userAgent.Contains("CPU iPhone OS 12", StringComparison.Ordinal) ||
userAgent.Contains("iPad; CPU OS 12", StringComparison.Ordinal))
{
return true;
}
// Cover Mac OS X based browsers that use the Mac OS networking stack.
// This includes:
// - Safari on Mac OS X.
// This does not include:
// - Chrome on Mac OS X
// because they do not use the Mac OS networking stack.
// Notes from Thinktecture:
// Regarding https://caniuse.com/#search=samesite MacOS X versions lower
// than 10.14 are not supporting SameSite at all. Starting with version
// 10.15 unknown values are NOT treated as strict anymore. Therefore we
// only need to check version 10.14.
if (userAgent.Contains("Safari", StringComparison.Ordinal) &&
userAgent.Contains("Macintosh; Intel Mac OS X 10_14", StringComparison.Ordinal) &&
userAgent.Contains("Version/", StringComparison.Ordinal))
{
return true;
}
// Cover Chrome 50-69, because some versions are broken by SameSite=None
// and none in this range require it.
// Note: this covers some pre-Chromium Edge versions,
// but pre-Chromium Edge does not require SameSite=None.
// Notes from Thinktecture:
// We can not validate this assumption, but we trust Microsofts
// evaluation. And overall not sending a SameSite value equals to the same
// behavior as SameSite=None for these old versions anyways.
if (userAgent.Contains("Chrome/5", StringComparison.Ordinal) ||
userAgent.Contains("Chrome/6", StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}

9
backend/src/Squidex.Web/Services/UrlGenerator.cs

@ -64,9 +64,9 @@ namespace Squidex.Web.Services
return assetFileStore.GeneratePublicUrl(appId.Id, assetId, fileVersion, null);
}
public string AssetsUI(NamedId<DomainId> appId, string? query = null)
public string AssetsUI(NamedId<DomainId> appId, string? @ref = null)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/assets", false) + query != null ? $"?query={query}" : string.Empty;
return urlGenerator.BuildUrl($"app/{appId.Name}/assets", false) + @ref != null ? $"?ref={@ref}" : string.Empty;
}
public string BackupsUI(NamedId<DomainId> appId)
@ -119,6 +119,11 @@ namespace Squidex.Web.Services
return urlGenerator.BuildUrl($"app/{appId.Name}/settings/roles", false);
}
public string Root()
{
return urlGenerator.BuildUrl();
}
public string RulesUI(NamedId<DomainId> appId)
{
return urlGenerator.BuildUrl($"app/{appId.Name}/rules", false);

2
backend/src/Squidex/Areas/Api/Config/OpenApi/CommonProcessor.cs

@ -36,8 +36,6 @@ namespace Squidex.Areas.Api.Config.OpenApi
public void Process(DocumentProcessorContext context)
{
context.Document.BasePath = Constants.PrefixApi;
context.Document.Info.Version = version;
context.Document.Info.ExtensionData = new Dictionary<string, object>
{

2
backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs

@ -93,7 +93,7 @@ namespace Squidex.Areas.Api.Config.OpenApi
ConfigureSchemaSettings(settings);
settings.OperationProcessors.Add(new QueryParamsProcessor("/apps/{app}/assets"));
settings.OperationProcessors.Add(new QueryParamsProcessor("/api/apps/{app}/assets"));
});
}

2
backend/src/Squidex/Areas/Api/Config/OpenApi/SecurityProcessor.cs

@ -26,7 +26,7 @@ namespace Squidex.Areas.Api.Config.OpenApi
Type = OpenApiSecuritySchemeType.OAuth2
};
var tokenUrl = urlGenerator.BuildUrl($"{Constants.PrefixIdentityServer}/connect/token", false);
var tokenUrl = urlGenerator.BuildUrl($"/{Constants.PrefixIdentityServer}/connect/token", false);
security.TokenUrl = tokenUrl;

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

@ -68,6 +68,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[LocalizedRequired]
public string MetadataText { get; set; }
/// <summary>
/// The UI token.
/// </summary>
public string? EditToken { get; set; }
/// <summary>
/// The asset metadata.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -77,6 +77,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary>
public string? NewStatusColor { get; set; }
/// <summary>
/// The UI token.
/// </summary>
public string? EditToken { get; set; }
/// <summary>
/// The scheduled status.
/// </summary>

2
backend/src/Squidex/Areas/Api/Controllers/Docs/DocsController.cs

@ -24,7 +24,7 @@ namespace Squidex.Areas.Api.Controllers.Docs
{
var vm = new DocsVM
{
Specification = "~/swagger/v1/swagger.json"
Specification = "~/api/swagger/v1/swagger.json"
};
return View(nameof(Docs), vm);

2
backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -63,7 +63,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// 200 => User resources returned.
/// </returns>
[HttpGet]
[Route("/")]
[Route("")]
[ProducesResponseType(typeof(ResourcesDto), StatusCodes.Status200OK)]
[ApiPermission]
public IActionResult GetUserResources()

36
backend/src/Squidex/Areas/Api/Startup.cs

@ -1,36 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Areas.Api.Config.OpenApi;
using Squidex.Web;
using Squidex.Web.Pipeline;
namespace Squidex.Areas.Api
{
public static class Startup
{
public static void ConfigureApi(this IApplicationBuilder app)
{
app.Map(Constants.PrefixApi, builder =>
{
builder.UseAccessTokenQueryString();
builder.UseRouting();
builder.UseAuthentication();
builder.UseAuthorization();
builder.UseSquidexOpenApi();
builder.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
});
}
}
}

37
backend/src/Squidex/Areas/Frontend/Middlewares/EmbedMiddleware.cs

@ -0,0 +1,37 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Areas.Frontend.Middlewares
{
public sealed class EmbedMiddleware
{
private readonly RequestDelegate next;
public EmbedMiddleware(RequestDelegate next)
{
this.next = next;
}
public Task InvokeAsync(HttpContext context)
{
var request = context.Request;
if (request.Path.StartsWithSegments("/embed", StringComparison.Ordinal, out var remaining))
{
request.Path = remaining;
var uiOptions = new OptionsFeature();
uiOptions.Options["embedded"] = true;
uiOptions.Options["embedPath"] = "/embed";
context.Features.Set(uiOptions);
}
return next(context);
}
}
}

25
backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs

@ -7,7 +7,6 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -26,13 +25,15 @@ namespace Squidex.Areas.Frontend.Middlewares
ContractResolver = new CamelCasePropertyNamesContractResolver()
});
public static bool IsIndex(this HttpContext context)
{
return context.Request.Path.StartsWithSegments("/index.html", StringComparison.OrdinalIgnoreCase);
}
public static string AddOptions(this string html, HttpContext httpContext)
{
const string Placeholder = "/* INJECT OPTIONS */";
if (!html.Contains(Placeholder, StringComparison.Ordinal))
{
return html;
}
var scripts = new List<string>
{
$"var texts = {GetText(CultureInfo.CurrentUICulture.Name)};"
@ -60,12 +61,22 @@ namespace Squidex.Areas.Frontend.Middlewares
json["more"]!["notifoApi"] = notifo.Value.ApiUrl;
}
var options = httpContext.Features.Get<OptionsFeature>();
if (options != null)
{
foreach (var (key, value) in options.Options)
{
json[key] = JToken.FromObject(value);
}
}
uiOptions.More["culture"] = CultureInfo.CurrentUICulture.Name;
scripts.Add($"var options = {json.ToString(Formatting.Indented)};");
}
html = html.Replace("<body>", $"<body>\n<script>{string.Join(Environment.NewLine, scripts)}</script>", StringComparison.OrdinalIgnoreCase);
html = html.Replace(Placeholder, string.Join(Environment.NewLine, scripts), StringComparison.OrdinalIgnoreCase);
return html;
}

2
backend/src/Squidex/Areas/Frontend/Middlewares/NotifoMiddleware.cs

@ -11,7 +11,7 @@ using Squidex.Domain.Apps.Entities.History;
namespace Squidex.Areas.Frontend.Middlewares
{
public class NotifoMiddleware
public sealed class NotifoMiddleware
{
private readonly RequestDelegate next;
private readonly string? workerUrl;

9
backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiExtensions.cs → backend/src/Squidex/Areas/Frontend/Middlewares/OptionsFeature.cs

@ -5,13 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Areas.Api.Config.OpenApi
namespace Squidex.Areas.Frontend.Middlewares
{
public static class OpenApiExtensions
public sealed class OptionsFeature
{
public static void UseSquidexOpenApi(this IApplicationBuilder app)
{
app.UseOpenApi();
}
public Dictionary<string, object> Options { get; } = new Dictionary<string, object>();
}
}

70
backend/src/Squidex/Areas/Frontend/Startup.cs

@ -16,26 +16,18 @@ namespace Squidex.Areas.Frontend
{
public static class Startup
{
public static void ConfigureFrontend(this IApplicationBuilder app)
public static void UseFrontend(this IApplicationBuilder app)
{
var environment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
var fileProvider = environment.WebRootFileProvider;
if (environment.IsProduction())
app.UseMiddleware<EmbedMiddleware>();
if (!environment.IsDevelopment())
{
fileProvider = new CompositeFileProvider(fileProvider,
new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "build")));
app.Use((context, next) =>
{
if (!Path.HasExtension(context.Request.Path.Value))
{
context.Request.Path = new PathString("/index.html");
}
return next();
});
}
app.Map("/squid.svg", builder =>
@ -45,24 +37,48 @@ namespace Squidex.Areas.Frontend
app.UseMiddleware<NotifoMiddleware>();
app.UseWhen(x => x.IsIndex(), builder =>
app.UseWhen(c => c.IsSpaFile(), builder =>
{
builder.UseMiddleware<SetupMiddleware>();
});
app.UseHtmlTransform(new HtmlTransformOptions
app.UseWhen(c => c.IsHtmlPath(), builder =>
{
Transform = (html, context) =>
// Adjust the base for all potential html files.
builder.UseHtmlTransform(new HtmlTransformOptions
{
if (context.IsIndex())
Transform = (html, context) =>
{
html = html.AddOptions(context);
return new ValueTask<string>(html.AddOptions(context));
}
});
});
return new ValueTask<string>(html);
}
app.Use((context, next) =>
{
return next();
});
app.UseSquidexStaticFiles(fileProvider);
if (!environment.IsDevelopment())
{
// Try static files again to serve index.html.
app.UsePathOverride("/index.html");
app.UseSquidexStaticFiles(fileProvider);
}
else
{
// Forward requests to SPA development server.
app.UseSpa(builder =>
{
builder.UseProxyToSpaDevelopmentServer("https://localhost:3000");
});
}
}
private static void UseSquidexStaticFiles(this IApplicationBuilder app, IFileProvider fileProvider)
{
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
@ -80,14 +96,16 @@ namespace Squidex.Areas.Frontend
},
FileProvider = fileProvider
});
}
if (environment.IsDevelopment())
{
app.UseSpa(builder =>
{
builder.UseProxyToSpaDevelopmentServer("https://localhost:3000");
});
}
private static bool IsSpaFile(this HttpContext context)
{
return context.IsIndex() || !Path.HasExtension(context.Request.Path);
}
private static bool IsHtmlPath(this HttpContext context)
{
return context.IsSpaFile() || context.Request.Path.Value?.Contains(".html", StringComparison.OrdinalIgnoreCase) == true;
}
}
}

29
backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs

@ -18,7 +18,6 @@ using Squidex.Hosting;
using Squidex.Web;
using Squidex.Web.Pipeline;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers;
using static OpenIddict.Server.OpenIddictServerEvents;
using static OpenIddict.Server.OpenIddictServerHandlers;
@ -79,20 +78,13 @@ namespace Squidex.Areas.IdentityServer.Config
})
.AddServer(builder =>
{
builder.RemoveEventHandler(ValidateTransportSecurityRequirement.Descriptor);
builder.AddEventHandler<ProcessSignInContext>(builder =>
{
builder.UseSingletonHandler<AlwaysAddTokenHandler>()
.SetOrder(AttachTokenParameters.Descriptor.Order + 1);
});
builder
.SetAuthorizationEndpointUris("/connect/authorize")
.SetIntrospectionEndpointUris("/connect/introspect")
.SetLogoutEndpointUris("/connect/logout")
.SetTokenEndpointUris("/connect/token")
.SetUserinfoEndpointUris("/connect/userinfo");
builder.SetConfigurationEndpointUris("/identity-server/.well-known/openid-configuration");
builder.DisableAccessTokenEncryption();
@ -127,7 +119,24 @@ namespace Squidex.Areas.IdentityServer.Config
{
var urlGenerator = services.GetRequiredService<IUrlGenerator>();
options.Issuer = new Uri(urlGenerator.BuildUrl("/identity-server", false));
var issuerUrl = Constants.PrefixIdentityServer;
options.Issuer = new Uri(urlGenerator.BuildUrl(issuerUrl, false));
options.AuthorizationEndpointUris.Add(
new Uri(urlGenerator.BuildUrl($"{issuerUrl}/connect/authorize", false)));
options.IntrospectionEndpointUris.Add(
new Uri(urlGenerator.BuildUrl($"{issuerUrl}/connect/introspect", false)));
options.LogoutEndpointUris.Add(
new Uri(urlGenerator.BuildUrl($"{issuerUrl}/connect/logout", false)));
options.TokenEndpointUris.Add(
new Uri(urlGenerator.BuildUrl($"{issuerUrl}/connect/token", false)));
options.UserinfoEndpointUris.Add(
new Uri(urlGenerator.BuildUrl($"{issuerUrl}/connect/userinfo", false)));
});
}
}

13
backend/src/Squidex/Areas/IdentityServer/Controllers/IdentityServerController.cs

@ -7,12 +7,13 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Hosting;
using Squidex.Web;
namespace Squidex.Areas.IdentityServer.Controllers
{
[Area("IdentityServer")]
[Route(Constants.PrefixIdentityServer)]
public abstract class IdentityServerController : Controller
{
public SignInManager<IdentityUser> SignInManager
@ -20,16 +21,6 @@ namespace Squidex.Areas.IdentityServer.Controllers
get => HttpContext.RequestServices.GetRequiredService<SignInManager<IdentityUser>>();
}
public override void OnActionExecuting(ActionExecutingContext context)
{
var request = context.HttpContext.Request;
if (!request.PathBase.HasValue || request.PathBase.Value?.EndsWith("/identity-server", StringComparison.OrdinalIgnoreCase) != true)
{
context.Result = new NotFoundResult();
}
}
protected IActionResult RedirectToReturnUrl(string? returnUrl)
{
if (string.IsNullOrWhiteSpace(returnUrl))

24
backend/src/Squidex/Areas/IdentityServer/Controllers/Info/InfoController.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using Squidex.Shared.Identity;
namespace Squidex.Areas.IdentityServer.Controllers.Info
{
public sealed class InfoController : IdentityServerController
{
[Route("info")]
[HttpGet]
public IActionResult Info()
{
var displayName = User.Claims.DisplayName();
return Ok(new { displayName });
}
}
}

20
backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -45,7 +45,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpGet]
[Route("/account/profile/")]
[Route("account/profile/")]
public async Task<IActionResult> Profile(string? successMessage = null)
{
var user = await userService.GetAsync(User, HttpContext.RequestAborted);
@ -54,7 +54,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/login-add/")]
[Route("account/profile/login-add/")]
public async Task<IActionResult> AddLogin(string provider)
{
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
@ -68,7 +68,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpGet]
[Route("/account/profile/login-add-callback/")]
[Route("account/profile/login-add-callback/")]
public Task<IActionResult> AddLoginCallback()
{
return MakeChangeAsync((id, ct) => AddLoginAsync(id, ct),
@ -76,7 +76,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/update/")]
[Route("account/profile/update/")]
public Task<IActionResult> UpdateProfile(ChangeProfileModel model)
{
return MakeChangeAsync((id, ct) => userService.UpdateAsync(id, model.ToValues(), ct: ct),
@ -84,7 +84,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/properties/")]
[Route("account/profile/properties/")]
public Task<IActionResult> UpdateProperties(ChangePropertiesModel model)
{
return MakeChangeAsync((id, ct) => userService.UpdateAsync(id, model.ToValues(), ct: ct),
@ -92,7 +92,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/login-remove/")]
[Route("account/profile/login-remove/")]
public Task<IActionResult> RemoveLogin(RemoveLoginModel model)
{
return MakeChangeAsync((id, ct) => userService.RemoveLoginAsync(id, model.LoginProvider, model.ProviderKey, ct),
@ -100,7 +100,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/password-set/")]
[Route("account/profile/password-set/")]
public Task<IActionResult> SetPassword(SetPasswordModel model)
{
return MakeChangeAsync((id, ct) => userService.SetPasswordAsync(id, model.Password, ct: ct),
@ -108,7 +108,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/password-change/")]
[Route("account/profile/password-change/")]
public Task<IActionResult> ChangePassword(ChangePasswordModel model)
{
return MakeChangeAsync((id, ct) => userService.SetPasswordAsync(id, model.Password, model.OldPassword, ct),
@ -116,7 +116,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/generate-client-secret/")]
[Route("account/profile/generate-client-secret/")]
public Task<IActionResult> GenerateClientSecret()
{
return MakeChangeAsync((id, ct) => GenerateClientSecretAsync(id, ct),
@ -124,7 +124,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
[HttpPost]
[Route("/account/profile/upload-picture/")]
[Route("account/profile/upload-picture/")]
public Task<IActionResult> UploadPicture(List<IFormFile> file)
{
return MakeChangeAsync((id, ct) => UpdatePictureAsync(file, id, ct),

5
backend/src/Squidex/Areas/IdentityServer/Controllers/UserInfo/UserInfoController.cs

@ -25,8 +25,9 @@ namespace Squidex.Areas.IdentityServer.Controllers.UserInfo
}
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("connect/userinfo")]
[HttpPost("connect/userinfo")]
[HttpGet]
[HttpPost]
[Route("connect/userinfo")]
[Produces("application/json")]
public async Task<IActionResult> UserInfo()
{

41
backend/src/Squidex/Areas/IdentityServer/Startup.cs

@ -1,41 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Web;
namespace Squidex.Areas.IdentityServer
{
public static class Startup
{
public static void ConfigureIdentityServer(this IApplicationBuilder app)
{
var environment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
app.Map(Constants.PrefixIdentityServer, builder =>
{
if (environment.IsDevelopment())
{
builder.UseDeveloperExceptionPage();
}
else
{
builder.UseExceptionHandler("/error");
}
builder.UseRouting();
builder.UseAuthentication();
builder.UseAuthorization();
builder.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
});
}
}
}

28
backend/src/Squidex/Areas/OrleansDashboard/Startup.cs

@ -1,28 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Orleans;
using Squidex.Areas.OrleansDashboard.Middlewares;
using Squidex.Web;
namespace Squidex.Areas.OrleansDashboard
{
public static class Startup
{
public static void ConfigureOrleansDashboard(this IApplicationBuilder app)
{
app.Map(Constants.PrefixOrleans, builder =>
{
builder.UseAuthentication();
builder.UseAuthorization();
builder.UseMiddleware<OrleansDashboardAuthenticationMiddleware>();
builder.UseOrleansDashboard();
});
}
}
}

27
backend/src/Squidex/Areas/Portal/Startup.cs

@ -1,27 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Areas.Portal.Middlewares;
using Squidex.Web;
namespace Squidex.Areas.Portal
{
public static class Startup
{
public static void ConfigurePortal(this IApplicationBuilder app)
{
app.Map(Constants.PrefixPortal, builder =>
{
builder.UseAuthentication();
builder.UseAuthorization();
builder.UseMiddleware<PortalDashboardAuthenticationMiddleware>();
builder.UseMiddleware<PortalRedirectMiddleware>();
});
}
}
}

12
backend/src/Squidex/Config/Authentication/AuthenticationServices.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Microsoft.AspNetCore.Authentication;
using Squidex.Hosting.Web;
namespace Squidex.Config.Authentication
{
@ -16,7 +17,7 @@ namespace Squidex.Config.Authentication
var identityOptions = config.GetSection("identity").Get<MyIdentityOptions>() ?? new ();
services.AddAuthentication()
.AddSquidexCookies()
.AddSquidexCookies(config)
.AddSquidexExternalGithubAuthentication(identityOptions)
.AddSquidexExternalGoogleAuthentication(identityOptions)
.AddSquidexExternalMicrosoftAuthentication(identityOptions)
@ -24,11 +25,18 @@ namespace Squidex.Config.Authentication
.AddSquidexIdentityServerAuthentication(identityOptions, config);
}
public static AuthenticationBuilder AddSquidexCookies(this AuthenticationBuilder builder)
public static AuthenticationBuilder AddSquidexCookies(this AuthenticationBuilder builder, IConfiguration config)
{
var urlsOptions = config.GetSection("urls").Get<UrlOptions>() ?? new ();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = ".sq.auth";
if (urlsOptions.BaseUrl?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) == true)
{
options.Cookie.SameSite = SameSiteMode.None;
}
});
return builder.AddCookie();

1
backend/src/Squidex/Config/Domain/AppsServices.cs

@ -15,7 +15,6 @@ using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
namespace Squidex.Config.Domain
{

3
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -55,6 +55,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<ConvertData>()
.As<IContentEnricherStep>();
services.AddSingletonAs<CalculateTokens>()
.As<IContentEnricherStep>();
services.AddSingletonAs<EnrichForCaching>()
.As<IContentEnricherStep>();

3
backend/src/Squidex/Config/Web/WebExtensions.cs

@ -133,7 +133,8 @@ namespace Squidex.Config.Web
public static void UseSquidexCors(this IApplicationBuilder app)
{
app.UseCors(builder => builder
.AllowAnyOrigin()
.SetIsOriginAllowed(x => true)
.AllowCredentials()
.AllowAnyMethod()
.AllowAnyHeader());
}

2
backend/src/Squidex/Squidex.csproj

@ -80,7 +80,7 @@
<PackageReference Include="Squidex.Assets.S3" Version="2.6.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="7.7.0" />
<PackageReference Include="Squidex.Hosting" Version="2.8.0" />
<PackageReference Include="Squidex.Hosting" Version="2.13.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq" Version="4.3.0" />

63
backend/src/Squidex/Startup.cs

@ -5,17 +5,17 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Areas.Api;
using Orleans;
using Squidex.Areas.Api.Config.OpenApi;
using Squidex.Areas.Frontend;
using Squidex.Areas.IdentityServer;
using Squidex.Areas.IdentityServer.Config;
using Squidex.Areas.OrleansDashboard;
using Squidex.Areas.Portal;
using Squidex.Areas.OrleansDashboard.Middlewares;
using Squidex.Areas.Portal.Middlewares;
using Squidex.Config.Authentication;
using Squidex.Config.Domain;
using Squidex.Config.Web;
using Squidex.Pipeline.Plugins;
using Squidex.Web;
using Squidex.Web.Pipeline;
namespace Squidex
@ -34,7 +34,6 @@ namespace Squidex
services.AddHttpClient();
services.AddMemoryCache();
services.AddHealthChecks();
services.AddNonBreakingSameSiteCookies();
services.AddDefaultWebServices(config);
services.AddDefaultForwardRules();
@ -82,19 +81,57 @@ namespace Squidex
app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt();
app.UseSquidexCacheKeys();
app.UseSquidexExceptionHandling();
app.UseSquidexUsage();
app.UseSquidexLogging();
app.UseSquidexLocalization();
app.UseSquidexLocalCache();
app.UseSquidexCors();
app.UseOpenApi(options =>
{
options.Path = "/api/swagger/v1/swagger.json";
});
app.ConfigureApi();
app.ConfigurePortal();
app.ConfigureOrleansDashboard();
app.ConfigureIdentityServer();
app.ConfigureFrontend();
app.UseWhen(c => c.Request.Path.StartsWithSegments(Constants.PrefixIdentityServer, StringComparison.OrdinalIgnoreCase), builder =>
{
builder.UseExceptionHandler("/error");
});
app.UseWhen(c => c.Request.Path.StartsWithSegments(Constants.PrefixApi, StringComparison.OrdinalIgnoreCase), builder =>
{
builder.UseSquidexCacheKeys();
builder.UseSquidexExceptionHandling();
builder.UseSquidexUsage();
builder.UseAccessTokenQueryString();
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.Map(Constants.PrefixPortal, builder =>
{
builder.UseMiddleware<PortalDashboardAuthenticationMiddleware>();
builder.UseMiddleware<PortalRedirectMiddleware>();
});
app.Map(Constants.PrefixOrleans, builder =>
{
builder.UseMiddleware<OrleansDashboardAuthenticationMiddleware>();
builder.UseOrleansDashboard();
});
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
// Return a 404 for all unresolved api requests.
app.Map(Constants.PrefixApi, builder =>
{
builder.Use404();
});
app.UseFrontend();
app.UsePlugins();
}

76
backend/src/Squidex/wwwroot/scripts/embed-sample.html

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<style>
.element {
border: 2px dashed red;
border-radius: 0;
box-sizing: border-box;
padding: 20px;
position: fixed;
text-align: center;
text-decoration: none;
width: 200px;
}
.element-left {
left: -100px;
}
.element-right {
right: -100px;
}
.element-top {
top: -30px;
}
.element-bottom {
bottom: -30px;
}
.element-left,
.element-center,
.element-right {
top: 50%;
margin-top: -30px;
}
.element-top,
.element-center,
.element-bottom {
left: 50%;
margin-left: -100px;
}
</style>
<script src="embed-sdk.js"></script>
</head>
<body>
<div class="element element-top" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Top Aligned
</div>
<div class="element element-right" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Right Aligned
</div>
<div class="element element-bottom" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Bottom Aligned
</div>
<div class="element element-left" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Left Aligned
</div>
<div class="element element-center" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Center
</div>
</body>
</html>

1
backend/src/Squidex/wwwroot/scripts/embed-sdk.css

@ -0,0 +1 @@
.squidex *,.squidex-overlay{box-sizing:border-box}.squidex-overlay{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;font-weight:400;position:fixed}.squidex-overlay-border{border:2px solid #3284f4;border-radius:0;pointer-events:none;position:fixed;z-index:100000}.squidex-overlay-toolbar{color:#fff;cursor:pointer;font-size:13px;font-weight:400;position:fixed;white-space:nowrap;z-index:100000}.squidex-overlay-links,.squidex-overlay-schema{display:inline-block;height:30px;padding:5px 8px 3px}.squidex-overlay-schema{background-color:#4a93f5;border:0;border-radius:0}.squidex-overlay-links{background-color:#3284f4;border:0;border-radius:0}.squidex-overlay-links a{color:#fff!important;margin-left:7px;text-decoration:none;text-overflow:inherit}.squidex-overlay-links a:hover{text-decoration:underline!important}.squidex-iframe{background-color:#fff;border:1px solid #dedfe3;border-radius:2px;bottom:50px;box-shadow:0 0 20px rgba(0,0,0,.2);left:50px;position:fixed;right:50px;top:50px}.squidex-iframe iframe{bottom:0;left:0;min-height:100%;min-width:100%;position:absolute;right:0;top:0}.squidex-iframe-close{-ms-flex-align:center;-ms-flex-pack:center;align-items:center;background-color:#000;border:0;border-radius:20px;box-shadow:0 0 4px rgba(0,0,0,.9);cursor:pointer;display:-ms-flexbox;display:flex;justify-content:center;max-height:30px;max-width:30px;min-height:30px;min-width:30px;opacity:.7;position:absolute;right:-15px;top:-15px;transition:opacity .3s ease-in;z-index:1000}.squidex-iframe-close:hover{box-shadow:0 0 4px #000;opacity:1}.squidex-iframe-close svg{fill:#fff}

2
backend/src/Squidex/wwwroot/scripts/embed-sdk.js

File diff suppressed because one or more lines are too long

1
backend/src/Squidex/wwwroot/scripts/embed-sdk.js.map

File diff suppressed because one or more lines are too long

17
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsSearchSourceTests.cs

@ -50,11 +50,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
var ctx = ContextWithPermission(permission.Id);
var asset1 = CreateAsset("logo-light.png");
var asset2 = CreateAsset("logo-dark.png");
var asset1 = CreateAsset("logo1.png");
var asset2 = CreateAsset("logo2.png");
A.CallTo(() => urlGenerator.AssetsUI(appId, "logo"))
.Returns("assets-url");
A.CallTo(() => urlGenerator.AssetsUI(appId, asset1.Id.ToString()))
.Returns("assets-url1");
A.CallTo(() => urlGenerator.AssetsUI(appId, asset2.Id.ToString()))
.Returns("assets-url2");
A.CallTo(() => assetQuery.QueryAsync(ctx, null, A<Q>.That.HasQuery("Filter: contains(fileName, 'logo'); Take: 5"), A<CancellationToken>._))
.Returns(ResultList.CreateFrom(2, asset1, asset2));
@ -63,13 +66,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
result.Should().BeEquivalentTo(
new SearchResults()
.Add("logo-light.png", SearchResultType.Asset, "assets-url")
.Add("logo-dark.png", SearchResultType.Asset, "assets-url"));
.Add("logo1.png", SearchResultType.Asset, "assets-url1")
.Add("logo2.png", SearchResultType.Asset, "assets-url2"));
}
private static IEnrichedAssetEntity CreateAsset(string fileName)
{
return new AssetEntity { FileName = fileName };
return new AssetEntity { FileName = fileName, Id = DomainId.NewGuid() };
}
private Context ContextWithPermission(string? permission = null)

38
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs

@ -91,12 +91,15 @@ namespace Squidex.Domain.Apps.Entities.Assets
}
[Fact]
public async Task Should_read_tags()
public async Task Should_read_tags_if_file_exists()
{
var tags = new Dictionary<string, Tag>();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.HasFileAsync(A<string>._, ct))
.Returns(true);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.Returns(tags);
@ -109,26 +112,47 @@ namespace Squidex.Domain.Apps.Entities.Assets
[Fact]
public async Task Should_read_tags_alias_if_file_exists()
{
var tags = new Dictionary<string, Tag>();
var alias = new Dictionary<string, string>();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.HasFileAsync(A<string>._, ct))
.Returns(true);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.Returns(tags);
.Returns(false).Once().Then.Returns(true);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, string>>(A<string>._, ct))
.Returns(alias);
await sut.RestoreAsync(context, ct);
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Tags == tags && x.Alias == alias)))
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Alias == alias)))
.MustHaveHappened();
}
[Fact]
public async Task Should_not_read_tags_if_no_file_exists()
{
var alias = new Dictionary<string, string>();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.HasFileAsync(A<string>._, ct))
.Returns(false);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, string>>(A<string>._, ct))
.Returns(alias);
await sut.RestoreAsync(context, ct);
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, string>>(A<string>._, ct))
.MustNotHaveHappened();
A.CallTo(() => context.Reader.ReadJsonAsync<Dictionary<string, Tag>>(A<string>._, ct))
.MustNotHaveHappened();
A.CallTo(() => tagService.RebuildTagsAsync(appId.Id, TagGroups.Assets, A<TagsExport>.That.Matches(x => x.Alias == alias)))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_backup_created_asset()
{

38
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs

@ -6,11 +6,13 @@
// ==========================================================================
using FakeItEasy;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets.Queries
@ -19,8 +21,10 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly IAssetMetadataSource assetMetadataSource1 = A.Fake<IAssetMetadataSource>();
private readonly IAssetMetadataSource assetMetadataSource2 = A.Fake<IAssetMetadataSource>();
private readonly IJsonSerializer jsonSerializer = A.Fake<IJsonSerializer>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly Context requestContext;
private readonly AssetEnricher sut;
@ -35,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
requestContext = Context.Anonymous(Mocks.App(appId));
sut = new AssetEnricher(tagService, assetMetadataSources, requestCache);
sut = new AssetEnricher(tagService, assetMetadataSources, requestCache, urlGenerator, jsonSerializer);
}
[Fact]
@ -163,5 +167,37 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
Assert.Equal(new HashSet<string> { "name1", "name2" }, result[0].TagNames);
Assert.Equal(new HashSet<string> { "name2", "name3" }, result[1].TagNames);
}
[Fact]
public async Task Should_not_compute_ui_tokens_for_frontend()
{
var source = new AssetEntity
{
AppId = appId
};
var result = await sut.EnrichAsync(new[] { source }, new Context(Mocks.FrontendUser(), Mocks.App(appId)), default);
Assert.Null(result[0].EditToken);
A.CallTo(() => urlGenerator.Root())
.MustNotHaveHappened();
}
[Fact]
public async Task Should_compute_ui_tokens()
{
var source = new AssetEntity
{
AppId = appId
};
var result = await sut.EnrichAsync(new[] { source }, requestContext, default);
Assert.NotNull(result[0].EditToken);
A.CallTo(() => urlGenerator.Root())
.MustHaveHappened();
}
}
}

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestAsset.cs

@ -24,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email
displayName
}
editToken
lastModified
lastModifiedBy
lastModifiedByUser {
@ -62,6 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
Version = 1,
Created = now,
CreatedBy = RefToken.User("user1"),
EditToken = $"token_{id}",
LastModified = now,
LastModifiedBy = RefToken.Client("client1"),
FileName = "MyFile.png",
@ -100,6 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email = $"{asset.CreatedBy.Identifier}@email.com",
displayName = $"name_{asset.CreatedBy.Identifier}"
},
editToken = $"token_{asset.Id}",
lastModified = asset.LastModified,
lastModifiedBy = asset.LastModifiedBy.ToString(),
lastModifiedByUser = new

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs

@ -24,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email
displayName
}
editToken
lastModified
lastModifiedBy
lastModifiedByUser {
@ -105,6 +106,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email
displayName
}
editToken
lastModified
lastModifiedBy
lastModifiedByUser {
@ -227,6 +229,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
Version = 1,
Created = now,
CreatedBy = RefToken.User("user1"),
EditToken = $"token_{id}",
LastModified = now,
LastModifiedBy = RefToken.Client("client1"),
Data = data,
@ -257,6 +260,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
Version = 1,
Created = now,
CreatedBy = RefToken.User("user1"),
EditToken = $"token_{id}",
LastModified = now,
LastModifiedBy = RefToken.User("user2"),
Data = data,
@ -284,6 +288,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email = $"{content.CreatedBy.Identifier}@email.com",
displayName = $"name_{content.CreatedBy.Identifier}"
},
editToken = $"token_{content.Id}",
lastModified = content.LastModified,
lastModifiedBy = content.LastModifiedBy.ToString(),
lastModifiedByUser = new
@ -315,6 +320,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
email = $"{content.CreatedBy.Identifier}@email.com",
displayName = $"name_{content.CreatedBy.Identifier}"
},
editToken = $"token_{content.Id}",
lastModified = content.LastModified,
lastModifiedBy = content.LastModifiedBy.ToString(),
lastModifiedByUser = new

84
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/CalculateTokensTests.cs

@ -0,0 +1,84 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using FakeItEasy;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
public class EnrichForCachingTests
{
private readonly ISchemaEntity schema;
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly Context requestContext;
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
private readonly ProvideSchema schemaProvider;
private readonly EnrichForCaching sut;
public EnrichForCachingTests()
{
requestContext = new Context(Mocks.ApiUser(), Mocks.App(appId));
schema = Mocks.Schema(appId, schemaId);
schemaProvider = x => Task.FromResult((schema, ResolvedComponents.Empty));
sut = new EnrichForCaching(requestCache);
}
[Fact]
public async Task Should_add_cache_headers()
{
var headers = new List<string>();
A.CallTo(() => requestCache.AddHeader(A<string>._))
.Invokes(new Action<string>(header => headers.Add(header)));
await sut.EnrichAsync(requestContext, default);
Assert.Equal(new List<string>
{
"X-Flatten",
"X-Languages",
"X-NoCleanup",
"X-NoEnrichment",
"X-NoResolveLanguages",
"X-ResolveFlow",
"X-Resolve-Urls",
"X-Unpublished"
}, headers);
}
[Fact]
public async Task Should_add_app_version_and_schema_as_dependency()
{
var content = CreateContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(content, 1), schemaProvider, default);
A.CallTo(() => requestCache.AddDependency(content.UniqueId, content.Version))
.MustHaveHappened();
A.CallTo(() => requestCache.AddDependency(schema.UniqueId, schema.Version))
.MustHaveHappened();
A.CallTo(() => requestCache.AddDependency(requestContext.App.UniqueId, requestContext.App.Version))
.MustHaveHappened();
}
private ContentEntity CreateContent()
{
return new ContentEntity { AppId = appId, Id = DomainId.NewGuid(), SchemaId = schemaId, Version = 13 };
}
}
}

52
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs

@ -6,79 +6,67 @@
// ==========================================================================
using FakeItEasy;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
public class EnrichForCachingTests
public class CalculateTokensTests
{
private readonly ISchemaEntity schema;
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly IJsonSerializer jsonSerializer = A.Fake<IJsonSerializer>();
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly Context requestContext;
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
private readonly ProvideSchema schemaProvider;
private readonly EnrichForCaching sut;
private readonly CalculateTokens sut;
public EnrichForCachingTests()
public CalculateTokensTests()
{
requestContext = new Context(Mocks.ApiUser(), Mocks.App(appId));
schema = Mocks.Schema(appId, schemaId);
schemaProvider = x => Task.FromResult((schema, ResolvedComponents.Empty));
sut = new EnrichForCaching(requestCache);
sut = new CalculateTokens(urlGenerator, jsonSerializer);
}
[Fact]
public async Task Should_add_cache_headers()
public async Task Should_not_compute_ui_tokens_for_frontend()
{
var headers = new List<string>();
var source = CreateContent();
A.CallTo(() => requestCache.AddHeader(A<string>._))
.Invokes(new Action<string>(header => headers.Add(header)));
await sut.EnrichAsync(new Context(Mocks.FrontendUser(), Mocks.App(appId)), new[] { source }, schemaProvider, default);
await sut.EnrichAsync(requestContext, default);
Assert.Null(source.EditToken);
Assert.Equal(new List<string>
{
"X-Flatten",
"X-Languages",
"X-NoCleanup",
"X-NoEnrichment",
"X-NoResolveLanguages",
"X-ResolveFlow",
"X-Resolve-Urls",
"X-Unpublished"
}, headers);
A.CallTo(() => urlGenerator.Root())
.MustNotHaveHappened();
}
[Fact]
public async Task Should_add_app_version_and_schema_as_dependency()
public async Task Should_compute_ui_tokens()
{
var content = CreateContent();
var source = CreateContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(content, 1), schemaProvider, default);
await sut.EnrichAsync(requestContext, new[] { source }, schemaProvider, default);
A.CallTo(() => requestCache.AddDependency(content.UniqueId, content.Version))
.MustHaveHappened();
A.CallTo(() => requestCache.AddDependency(schema.UniqueId, schema.Version))
.MustHaveHappened();
Assert.NotNull(source.EditToken);
A.CallTo(() => requestCache.AddDependency(requestContext.App.UniqueId, requestContext.App.Version))
A.CallTo(() => urlGenerator.Root())
.MustHaveHappened();
}
private ContentEntity CreateContent()
{
return new ContentEntity { AppId = appId, Id = DomainId.NewGuid(), SchemaId = schemaId, Version = 13 };
return new ContentEntity { AppId = appId, SchemaId = schemaId };
}
}
}

7
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
return $"contents/{schemaId.Name}/{contentId}";
}
public string AssetsUI(NamedId<DomainId> appId, string? query = null)
public string AssetsUI(NamedId<DomainId> appId, string? @ref = null)
{
throw new NotSupportedException();
}
@ -95,6 +95,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
throw new NotSupportedException();
}
public string Root()
{
throw new NotSupportedException();
}
public string RulesUI(NamedId<DomainId> appId)
{
throw new NotSupportedException();

35
backend/tools/TestSuite/TestSuite.ApiTests/OpenApiTests.cs

@ -0,0 +1,35 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NSwag;
using TestSuite.Fixtures;
using Xunit;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace TestSuite.ApiTests
{
public class OpenApiTests : IClassFixture<ClientManagerFixture>
{
public ClientManagerFixture _ { get; }
public OpenApiTests(ClientManagerFixture fixture)
{
_ = fixture;
}
[Fact]
public async Task Should_provide_spec()
{
var url = $"{_.ClientManager.Options.Url}/api/swagger/v1/swagger.json";
var document = await OpenApiDocument.FromUrlAsync(url);
Assert.NotNull(document);
}
}
}

1
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -19,6 +19,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NSwag.Core" Version="13.15.5" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">

2
backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -21,7 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.0.1" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup>

9
frontend/src/app/app.module.ts

@ -8,7 +8,7 @@
/* eslint-disable global-require */
/* eslint-disable import/no-dynamic-require */
import { CommonModule } from '@angular/common';
import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { ApplicationRef, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -21,7 +21,9 @@ import { routing } from './app.routes';
import { ApiUrlConfig, DateHelper, LocalizerService, SqxFrameworkModule, SqxSharedModule, TitlesConfig, UIOptions } from './shared';
import { SqxShellModule } from './shell';
DateHelper.setlocale(window['options']?.more?.culture);
const options = window['options'] || {};
DateHelper.setlocale(options.more?.culture);
function configApiUrl() {
const baseElements = document.getElementsByTagName('base');
@ -44,7 +46,7 @@ function configApiUrl() {
}
function configUIOptions() {
return new UIOptions(window['options']);
return new UIOptions(options);
}
function configTitles() {
@ -84,6 +86,7 @@ export class AppRouteReuseStrategy extends BaseRouteReuseStrategy {
{ provide: RouteReuseStrategy, useClass: AppRouteReuseStrategy },
{ provide: TitlesConfig, useFactory: configTitles },
{ provide: UIOptions, useFactory: configUIOptions },
{ provide: APP_BASE_HREF, useValue: options.embedPath || '/' },
],
entryComponents: [AppComponent],
})

1
frontend/src/app/features/assets/pages/assets-page.component.ts

@ -39,6 +39,7 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
const initial =
this.assetsRoute.mapTo(this.assetsState)
.withPaging('assets', 30)
.withString('ref')
.withStringOr('parentId', MathHelper.EMPTY_GUID)
.withStrings('tagsSelected')
.withSynchronizer(QueryFullTextSynchronizer.INSTANCE)

2
frontend/src/app/features/content/pages/schemas/schemas-page.component.html

@ -1,6 +1,6 @@
<sqx-title message="i18n:contents.schemasPageTitle"></sqx-title>
<sqx-layout layout="left" titleCollapsed="i18n:common.schemas" [width]="18" [white]="true" [padding]="true" [overflow]="true">
<sqx-layout layout="left" titleCollapsed="i18n:common.schemas" [width]="18" [white]="true" [padding]="true" [overflow]="true" *ngIf="!isEmbedded">
<ng-container menu>
<div class="search-form">
<input class="form-control" [formControl]="schemasFilter" placeholder="{{ 'contents.searchSchemasPlaceholder' | sqxTranslate }}">

7
frontend/src/app/features/content/pages/schemas/schemas-page.component.ts

@ -9,7 +9,7 @@ import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppsState, getCategoryTree, LocalStoreService, SchemaCategory, SchemasState, Settings, value$ } from '@app/shared';
import { AppsState, getCategoryTree, LocalStoreService, SchemaCategory, SchemasState, Settings, UIOptions, value$ } from '@app/shared';
@Component({
selector: 'sqx-schemas-page',
@ -19,6 +19,8 @@ import { AppsState, getCategoryTree, LocalStoreService, SchemaCategory, SchemasS
export class SchemasPageComponent {
public schemasFilter = new FormControl();
public isEmbedded = false;
public schemas =
this.schemasState.schemas.pipe(
map(schemas => {
@ -47,12 +49,13 @@ export class SchemasPageComponent {
return this.isCollapsed ? '4rem' : '16rem';
}
constructor(
constructor(uiOptions: UIOptions,
public readonly schemasState: SchemasState,
private readonly appsState: AppsState,
private readonly localStore: LocalStoreService,
) {
this.isCollapsed = localStore.getBoolean(Settings.Local.SCHEMAS_COLLAPSED);
this.isEmbedded = uiOptions.get('embedded');
}
public toggle() {

18
frontend/src/app/shared/services/assets.service.spec.ts

@ -219,6 +219,24 @@ describe('AssetsService', () => {
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets by ref',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const value = '1', op = 'eq';
assetsService.getAssets('my-app', { ref: value }).subscribe();
const expectedQuery = { filter: { or: [{ path: 'id', op, value }, { path: 'slug', op, value }] }, take: 1 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toEqual('1');
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] });
}));
it('should make post request to create asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: AssetDto;

36
frontend/src/app/shared/services/assets.service.ts

@ -161,13 +161,16 @@ export type MoveAssetItemDto =
Readonly<{ parentId?: string }>;
export type AssetsQuery =
Readonly<{ noTotal?: boolean; parentId?: string }>;
Readonly<{ noTotal?: boolean }>;
export type AssetsByRef =
Readonly<{ ref: string }>;
export type AssetsByIds =
Readonly<{ ids: ReadonlyArray<string> }> & AssetsQuery;
Readonly<{ ids: ReadonlyArray<string> }>;
export type AssetsByQuery =
Readonly<{ query?: Query; skip?: number; tags?: Tags; take?: number; noTotal?: boolean }> & AssetsQuery;
Readonly<{ query?: Query; skip?: number; tags?: Tags; take?: number; parentId?: string }>;
type AssetsResponse =
Readonly<{ total: number; items: any[]; folders: any[] } & Resource>;
@ -198,14 +201,14 @@ export class AssetsService {
pretifyError('i18n:assets.loadTagsFailed'));
}
public getAssets(appName: string, q?: AssetsByQuery | AssetsByIds): Observable<AssetsDto> {
public getAssets(appName: string, q?: AssetsQuery & (AssetsByQuery | AssetsByIds | AssetsByRef)): Observable<AssetsDto> {
const body = buildQuery(q as any);
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/query`);
let options = {};
if (q?.noTotal) {
if (q?.noTotal || q?.['ref']) {
options = {
headers: {
'X-NoTotal': '1',
@ -393,8 +396,8 @@ export class AssetsService {
}
}
function buildQuery(q?: AssetsByQuery & AssetsByIds) {
const { ids, parentId, query, skip, tags, take } = q || {};
function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef) {
const { ids, parentId, query, ref, skip, tags, take } = q || {};
const body: any = {};
@ -402,7 +405,24 @@ function buildQuery(q?: AssetsByQuery & AssetsByIds) {
body.parentId = parentId;
}
if (Types.isArray(ids)) {
if (ref) {
const queryObj: Query = {
filter: {
or: [{
path: 'id',
op: 'eq',
value: ref,
}, {
path: 'slug',
op: 'eq',
value: ref,
}],
},
take: 1,
};
body.q = sanitize(queryObj);
} else if (Types.isArray(ids)) {
body.ids = ids;
} else {
const queryObj: Query = {};

15
frontend/src/app/shared/state/assets.state.spec.ts

@ -115,7 +115,7 @@ describe('AssetsState', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.load().subscribe();
assetsState.load().subscribe();
assetsState.page({ page: 1, pageSize: 30 }).subscribe();
expect().nothing();
@ -165,6 +165,19 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.query).toEqual(query);
});
it('should unset ref when searching', () => {
const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query }))
.returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.next({ ref: '1' });
assetsState.search(query).subscribe();
expect(assetsState.snapshot.query).toEqual(query);
expect(assetsState.snapshot.ref).toBeNull(null);
});
});
describe('Updates', () => {

10
frontend/src/app/shared/state/assets.state.ts

@ -39,6 +39,9 @@ interface Snapshot extends ListState<Query> {
// The folder path.
path: ReadonlyArray<AssetPathItem>;
// The ref asset.
ref?: string | null;
// The parent folder.
parentId: string;
@ -395,7 +398,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
}
private searchInternal(query?: Query | null, tags?: TagsSelected) {
const update: Partial<Snapshot> = { page: 0 };
const update: Partial<Snapshot> = { page: 0, ref: null };
if (query !== null) {
update.query = query;
@ -459,6 +462,7 @@ function updateTags(snapshot: Snapshot, newAsset?: AssetDto, oldAsset?: AssetDto
function createQuery(snapshot: Snapshot) {
const {
ref,
page,
pageSize,
query,
@ -470,7 +474,9 @@ function createQuery(snapshot: Snapshot) {
const tags = Object.keys(tagsSelected);
if (Types.isString(query?.fullText) || tags.length > 0) {
if (Types.isString(ref)) {
result.ref = ref;
} else if (Types.isString(query?.fullText) || tags.length > 0) {
if (query) {
result.query = query;
}

4
frontend/src/app/shell/pages/internal/internal-area.component.html

@ -1,4 +1,4 @@
<nav class="navbar navbar-light navbar-expand fixed-top bg-white align-items-center">
<nav class="navbar navbar-light navbar-expand fixed-top bg-white align-items-center" *ngIf="!isEmbedded">
<span class="navbar-brand align-items-center justify-content-center d-flex" routerLink="/app">
<sqx-logo [isLoading]="loadingService.loading | async"></sqx-logo>
</span>
@ -15,6 +15,6 @@
</div>
</nav>
<div class="main">
<div class="main" [class.top]="isEmbedded">
<router-outlet></router-outlet>
</div>

20
frontend/src/app/shell/pages/internal/internal-area.component.scss

@ -14,4 +14,24 @@
font-size: 1.8rem;
font-weight: normal;
width: $size-sidebar-width;
}
.main {
&.top {
margin-top: -$size-navbar-height;
}
&.top ::ng-deep {
.panel-container {
top: 0;
}
.panel-container {
left: 0;
}
.sidebar {
display: none;
}
}
}

8
frontend/src/app/shell/pages/internal/internal-area.component.ts

@ -7,7 +7,7 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DialogService, LoadingService, Notification, ResourceOwner } from '@app/shared';
import { DialogService, LoadingService, Notification, ResourceOwner, UIOptions } from '@app/shared';
@Component({
selector: 'sqx-internal-area',
@ -15,12 +15,16 @@ import { DialogService, LoadingService, Notification, ResourceOwner } from '@app
templateUrl: './internal-area.component.html',
})
export class InternalAreaComponent extends ResourceOwner implements OnInit {
constructor(
public isEmbedded = false;
constructor(uiOptions: UIOptions,
public readonly loadingService: LoadingService,
private readonly dialogs: DialogService,
private readonly route: ActivatedRoute,
) {
super();
this.isEmbedded = !!uiOptions.get('embedded');
}
public ngOnInit() {

4
frontend/src/index.html

@ -57,5 +57,9 @@
</sqx-app>
<div id="outdated"></div>
<script>
/* INJECT OPTIONS */
</script>
</body>
</html>

3
sdk/.gitignore

@ -0,0 +1,3 @@
node_modules
/build
/*.log

19
sdk/README.md

@ -0,0 +1,19 @@
# sdk
## CLI Commands
* `npm install`: Installs dependencies
* `npm run dev`: Run a development, HMR server
* `npm run serve`: Run a production-like server
* `npm run build`: Production-ready build
* `npm run lint`: Pass TypeScript files using ESLint
* `npm run test`: Run Jest and Enzyme with
[`enzyme-adapter-preact-pure`](https://github.com/preactjs/enzyme-adapter-preact-pure) for
your tests
For detailed explanation on how things work, checkout the [CLI Readme](https://github.com/developit/preact-cli/blob/master/README.md).

37655
sdk/package-lock.json

File diff suppressed because it is too large

42
sdk/package.json

@ -0,0 +1,42 @@
{
"private": true,
"name": "sdk",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"build": "preact build --prerender false --sw false --esm false --inline-css",
"dev": "preact watch",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"serve": "sirv build --port 8080 --cors --single",
"start": "preact watch"
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"extends": [
"preact",
"plugin:@typescript-eslint/recommended"
],
"ignorePatterns": [
"build/"
]
},
"dependencies": {
"preact": "^10.3.1",
"preact-render-to-string": "^5.1.4"
},
"devDependencies": {
"@types/enzyme": "^3.10.5",
"@types/jest": "^26.0.8",
"@typescript-eslint/eslint-plugin": "^2.25.0",
"@typescript-eslint/parser": "^2.25.0",
"enzyme": "^3.11.0",
"enzyme-adapter-preact-pure": "^3.1.0",
"eslint": "^6.8.0",
"eslint-config-preact": "^1.1.1",
"preact-cli": "^3.0.0",
"sass": "^1.49.0",
"sass-loader": "^10.2.1",
"sirv-cli": "^1.0.0-next.3",
"typescript": "^4.5.2"
}
}

1
sdk/size-plugin.json

@ -0,0 +1 @@
[{"timestamp":1643040747792,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":0,"diff":-665},{"filename":"index.html","previous":971,"size":982,"diff":11},{"filename":"bundle.*****.js","previous":7778,"size":7944,"diff":166},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0},{"filename":"bundle.9727e.css","previous":0,"size":686,"diff":686}]},{"timestamp":1643037266163,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":951,"size":971,"diff":20},{"filename":"bundle.*****.js","previous":7778,"size":7778,"diff":0},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1643037028401,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":861,"size":951,"diff":90},{"filename":"bundle.*****.js","previous":7778,"size":7778,"diff":0},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1643036552475,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":860,"size":861,"diff":1},{"filename":"bundle.*****.js","previous":7779,"size":7778,"diff":-1},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642880373297,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":770,"size":860,"diff":90},{"filename":"bundle.*****.js","previous":7165,"size":7779,"diff":614},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642793188355,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":770,"size":770,"diff":0},{"filename":"bundle.*****.js","previous":7169,"size":7165,"diff":-4},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642792886072,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":771,"size":770,"diff":-1},{"filename":"bundle.*****.js","previous":7165,"size":7169,"diff":4},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642792872086,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":770,"size":771,"diff":1},{"filename":"bundle.*****.js","previous":7122,"size":7165,"diff":43},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642792547160,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"index.html","previous":772,"size":770,"diff":-2},{"filename":"bundle.*****.js","previous":7029,"size":7122,"diff":93},{"filename":"polyfills.*****.js","previous":2291,"size":2291,"diff":0}]},{"timestamp":1642792111367,"files":[{"filename":"bundle.8ec41.css","previous":665,"size":665,"diff":0},{"filename":"bundle.*****.esm.js","previous":6968,"size":0,"diff":-6968},{"filename":"polyfills.*****.esm.js","previous":2191,"size":0,"diff":-2191},{"filename":"sw.js","previous":10444,"size":0,"diff":-10444},{"filename":"sw-esm.js","previous":10447,"size":0,"diff":-10447},{"filename":"bundle.dbe17.js","previous":7062,"size":0,"diff":-7062},{"filename":"polyfills.03377.js","previous":2291,"size":0,"diff":-2291},{"filename":"index.html","previous":806,"size":772,"diff":-34},{"filename":"200.html","previous":806,"size":0,"diff":-806},{"filename":"bundle.*****.js","previous":0,"size":7029,"diff":7029},{"filename":"polyfills.*****.js","previous":0,"size":2291,"diff":2291}]},{"timestamp":1642792036250,"files":[{"filename":"bundle.8ec41.css","previous":0,"size":665,"diff":665},{"filename":"bundle.*****.esm.js","previous":0,"size":6968,"diff":6968},{"filename":"polyfills.*****.esm.js","previous":0,"size":2191,"diff":2191},{"filename":"sw.js","previous":0,"size":10444,"diff":10444},{"filename":"sw-esm.js","previous":0,"size":10447,"diff":10447},{"filename":"bundle.dbe17.js","previous":0,"size":7062,"diff":7062},{"filename":"polyfills.03377.js","previous":0,"size":2291,"diff":2291},{"filename":"index.html","previous":0,"size":806,"diff":806},{"filename":"200.html","previous":0,"size":806,"diff":806}]}]

5
sdk/src/.babelrc

@ -0,0 +1,5 @@
{
"presets": [
"preact-cli/babel"
]
}

24
sdk/src/components/iframe.tsx

@ -0,0 +1,24 @@
import { h } from 'preact';
export interface IFrameProps {
// The url to embed.
url: string;
// When closed.
onClose: () => void;
}
export const IFrame = (props: IFrameProps) => {
const { url, onClose } = props;
return (
<div class='squidex-iframe'>
<button class='squidex-iframe-close' onClick={onClose}>
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'>
<path d='M18.984 6.422l-5.578 5.578 5.578 5.578-1.406 1.406-5.578-5.578-5.578 5.578-1.406-1.406 5.578-5.578-5.578-5.578 1.406-1.406 5.578 5.578 5.578-5.578z'></path>
</svg>
</button>
<iframe src={url} frameBorder={0}></iframe>
</div>
);
};

230
sdk/src/components/overlay-container.tsx

@ -0,0 +1,230 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { IFrame } from './iframe';
import { Overlay } from './Overlay';
import { TokenInfo } from './shared';
export interface OverlayContainerProps {
// The base url of the script.
baseUrl: string | null | undefined;
}
type AuthState = 'Authenticated' | 'Failed' | 'Pending';
const UNSET = { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY };
export const OverlayContainer = (props: OverlayContainerProps) => {
const div = useRef<any>();
const [auth, setAuth] = useState<{ [url: string]: AuthState }>({});
const [target, setTarget] = useState<{ target: HTMLElement, token: TokenInfo }>();
const [targetUrl, setTargetUrl] = useState<string>();
const authRef = useRef(auth);
useEffect(() => {
let previousElement: any;
let previousTarget: HTMLElement | undefined = undefined;
let previousPosition = UNSET;
const updateTarget = (target?: HTMLElement, token?: TokenInfo) => {
if (target !== previousTarget) {
if (target && token) {
setTarget({ target, token });
} else {
setTarget(undefined);
}
previousTarget = target;
}
};
function listen(event: MouseEvent) {
const element = event.target as HTMLElement;
console.log(element);
if (!element || isToolbar(element)) {
return;
}
const { token, target } = parseTokenInPath(element, Object.keys(authRef.current));
const position = { x: event.clientX, y: event.clientY };
if (token && target) {
updateTarget(target, token);
previousPosition = position;
} else if (Math.abs(position.x - previousPosition.x) + Math.abs(position.y - previousPosition.y) > 20) {
updateTarget(undefined, undefined);
}
previousElement = element;
}
document.addEventListener('mousemove', listen);
return () => {
document.removeEventListener('mousemove', listen);
}
}, []);
const checkAuth = useCallback((url: string | null | undefined) => {
if (!url) {
return;
}
if (authRef.current[url]) {
return;
}
const updateAuth = (state: AuthState) => {
const newAuth = {
...authRef.current,
[url]: state
};
authRef.current = newAuth;
setAuth(newAuth);
};
if (url.indexOf('http://') === 0) {
updateAuth('Authenticated');
return;
}
const fetchStatus = async () => {
updateAuth('Pending');
try {
const response = await fetch(`${url}/identity-server/info`, {
credentials: 'include'
});
const json = await response.json();
updateAuth(json.displayName ? 'Authenticated' : 'Failed');
} catch {
updateAuth('Failed');
}
};
fetchStatus();
}, [auth]);
useEffect(() => {
checkAuth(props.baseUrl);
}, [props.baseUrl]);
useEffect(() => {
checkAuth(target?.token.u);
}, [target?.token.u]);
const isAuthenticated = auth[target?.token.u!] === 'Authenticated'
return (
<div class='squidex' ref={div}>
{target && isAuthenticated &&
<Overlay onOpen={setTargetUrl} {...target} />
}
{targetUrl &&
<IFrame url={targetUrl} onClose={() => setTargetUrl(undefined)} />
}
</div>
);
}
const CDN_URL = 'https://assets.squidex.io';
function isToolbar(target: HTMLElement) {
let current = target;
while (current) {
if (current.className === 'squidex-overlay-toolbar') {
return true;
}
current = current.parentElement as HTMLElement;
}
return false;
}
function parseTokenInPath(target: HTMLElement, baseUrls: string[]): { token?: TokenInfo, target?: HTMLElement } {
let current = target;
while (current) {
const token = parseToken(current, baseUrls);
if (token) {
return { token, target: current };
}
current = current.parentElement as HTMLElement;
}
return {};
}
function parseToken(target: HTMLElement, baseUrls: string[]): TokenInfo | null {
const value = target.getAttribute('squidex-token');
if (!value && target.nodeName === 'IMG') {
const src = (target as any)['src'] as string;
if (src) {
for (const baseUrl of baseUrls) {
if (src.indexOf(baseUrl) === 0) {
const parts = src.substring(baseUrl.length + 1).split('/');
if (parts[0] === 'api' &&
parts[1] === 'assets' &&
parts[2]?.length > 0 &&
parts[3]?.length > 0) {
return {
u: baseUrl,
a: parts[2],
i: parts[3]
};
}
}
}
if (src.indexOf(CDN_URL) === 0) {
const parts = src.substring(CDN_URL.length + 1).split('/');
if (parts[0]?.length > 0 &&
parts[1]?.length > 0) {
return {
u: CDN_URL,
a: parts[0],
i: parts[1]
};
}
}
}
}
if (!value) {
return null;
}
try {
const decoded = atob(value);
let token = JSON.parse(decoded) as TokenInfo;
if (!token.u || !token.i || !token.a) {
return null;
}
while (token.u.endsWith('/')) {
token.u = token.u.substring(0, token.u.length - 1);
}
return token;
} catch {
return null;
}
}

147
sdk/src/components/overlay.tsx

@ -0,0 +1,147 @@
import { h } from 'preact';
import { useEffect, useState, useMemo, useRef, useLayoutEffect } from 'preact/hooks';
import { TokenInfo } from './shared';
export interface OverlayProps {
// The target element ot attach to.
target: HTMLElement;
// The token string.
token: TokenInfo;
// When opened.
onOpen: (url: string) => void;
}
const PADDING = 2;
export const Overlay = (props: OverlayProps) => {
const { onOpen, target, token } = props;
const toolbarRef = useRef<HTMLDivElement>();
const linksRect = useRef<DOMRect>();
const targetRect = useRef<DOMRect>();
const [_, render] = useState(0);
useEffect(() => {
render(x => x + 1);
}, [token]);
useEffect(() => {
function layout() {
const rect = target.getBoundingClientRect();
const current = targetRect.current;
if (!current ||
rect.height !== current.height ||
rect.width !== current.width ||
rect.x !== current.x ||
rect.y !== current.y) {
targetRect.current = rect;
render(x => x + 1);
}
}
document.body.addEventListener('scroll', layout);
const timer = setInterval(() => {
layout();
}, 500);
layout();
return () => {
clearInterval(timer);
document.body.removeEventListener('scroll', layout);
};
}, [target]);
useLayoutEffect(() => {
if (!toolbarRef.current) {
return;
}
const rect = toolbarRef.current.getBoundingClientRect();
const current = linksRect.current;
if (!current ||
rect.height !== current.height ||
rect.width !== current.width) {
linksRect.current = rect;
render(x => x + 1);
}
}, [render]);
const externalUrl = useMemo(() => {
let { a, i, s, u } = token;
if (s) {
return `${u}/app/${a}/content/${s}/${i}`;
} else {
return `${u}/app/${a}/assets/?ref=${i}`;
}
}, [token]);
const embedUrl = useMemo(() => {
let { a, i, s, u } = token;
if (s) {
return `${u}/embed/app/${a}/content/${s}/${i}`;
} else {
return `${u}/embed/app/${a}/assets/?ref=${i}`;
}
}, [token]);
const x = (targetRect.current?.x || 0) - PADDING;
const y = (targetRect.current?.y || 0) - PADDING;
const overlayWidth = (targetRect.current?.width || 0) + 2 * PADDING;
const overlayHeight = (targetRect.current?.height || 0) + 2 * PADDING;
const toolbarWidth = (linksRect.current?.width || 0);
const toolbarHeight = (linksRect.current?.height || 0);
let linkX = x;
let linkY = y - toolbarHeight + 2;
if (linkY < 0) {
linkY = y + overlayHeight - 2;
}
if (linkY + toolbarHeight > window.innerHeight) {
linkX = window.innerHeight - toolbarHeight;
}
if (linkX < 0) {
linkX = 0;
}
if (linkX + toolbarWidth > window.innerWidth) {
linkX = window.innerWidth - toolbarWidth;
}
return (
<div class='squidex-overlay'>
<div class='squidex-overlay-border' style={{ left: x, top: y, width: overlayWidth, height: overlayHeight }}>
</div>
<div class='squidex-overlay-toolbar' style={{ left: linkX, top: linkY }} ref={toolbarRef as any}>
<div class='squidex-overlay-schema'>
{token?.s || 'Asset'}
</div>
<div class='squidex-overlay-links' data-links={true}>
<a onClick={() => onOpen(embedUrl)}>
Edit Inline
</a>
<a href={externalUrl!} target='_blank'>
Edit In Squidex
</a>
</div>
</div>
</div>
);
};

13
sdk/src/components/shared.ts

@ -0,0 +1,13 @@
export interface TokenInfo {
// The content or asset id.
i: string;
// The app name.
a: string;
// The schema name.
s?: string;
// The base url.
u: string;
}

37
sdk/src/index.ts

@ -0,0 +1,37 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import './style/index.scss';
import { renderOverlay } from './render';
import { getBaseUrl } from './utils';
const baseUrl = getBaseUrl();
function init() {
renderOverlay(baseUrl);
renderStyles();
}
function renderStyles() {
if (!baseUrl) {
return;
}
const styleElement = document.createElement('link');
styleElement.rel = 'stylesheet';
styleElement.href = `${baseUrl}/scripts/embed-sdk.css`;
styleElement.type = 'text/css';
document.head?.appendChild(styleElement);
}
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', () => init());
}

21
sdk/src/manifest.json

@ -0,0 +1,21 @@
{
"name": "sdk",
"short_name": "sdk",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fff",
"theme_color": "#673ab8",
"icons": [
{
"src": "/assets/icons/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/assets/icons/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

19
sdk/src/render.tsx

@ -0,0 +1,19 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { h, render } from 'preact';
import { OverlayContainer } from './components/overlay-container';
let element: HTMLDivElement | null = null;
export function renderOverlay(baseUrl: string | null | undefined) {
if (!element) {
element = document.body.appendChild(document.createElement('div'));
}
render(<OverlayContainer baseUrl={baseUrl} />, element)
}

125
sdk/src/style/index.scss

@ -0,0 +1,125 @@
$color-theme-brand: #3389ff;
$color-theme-brand-dark: #3284f4;
$color-theme-brand-darker: #2f7deb;
$color-border: #dedfe3;
@mixin position($t: null, $r: null, $b: null, $l: null) {
bottom: $b; left: $l; right: $r; top: $t;
}
@mixin absolute($t: null, $r: null, $b: null, $l: null) {
@include position($t, $r, $b, $l);
position: absolute;
}
@mixin fixed($t: null, $r: null, $b: null, $l: null) {
@include position($t, $r, $b, $l);
position: fixed;
}
.squidex {
* {
box-sizing: border-box;
}
&-overlay {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
font-weight: normal;
position: fixed;
&-border {
border: 2px solid $color-theme-brand-dark;
border-radius: 0;
position: fixed;
pointer-events: none;
z-index: 100000;
}
&-toolbar {
color: #fff;
cursor: pointer;
font-size: 13px;
font-weight: normal;
position: fixed;
white-space: nowrap;
z-index: 100000;
}
&-schema,
&-links {
display: inline-block;
height: 30px;
padding: 3px 8px;
padding-top: 5px;
}
&-schema {
background-color: lighten($color-theme-brand-dark, 5%);
border: 0;
border-radius: 0;
}
&-links {
background-color: $color-theme-brand-dark;
border: 0;
border-radius: 0;
a {
margin-left: 7px;
text-overflow: inherit;
text-decoration: none;
& {
color: #fff !important;
}
&:hover {
text-decoration: underline !important;
}
}
}
}
&-iframe {
@include fixed(50px, 50px, 50px, 50px);
background-color: white;
border: 1px solid $color-border;
border-radius: 2px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
iframe {
@include absolute(0, 0, 0, 0);
min-width: 100%;
min-height: 100%;
}
&-close {
@include absolute(-15px, -15px);
align-items: center;
background-color: black;
border-radius: 20px;
border: 0;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
cursor: pointer;
display: flex;
max-height: 30px;
max-width: 30px;
min-height: 30px;
min-width: 30px;
justify-content: center;
transition: opacity .3s ease-in;
opacity: .7;
z-index: 1000;
&:hover {
box-shadow: 0 0 4px rgba(0, 0, 0, 1);
opacity: 1;
}
svg {
fill: white;
}
}
}
}

99
sdk/src/template.html

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><% preact.title %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
<% preact.headEnd %>
<style>
.element {
border: 2px dashed red;
border-radius: 0;
box-sizing: border-box;
padding: 20px;
position: fixed;
text-align: center;
text-decoration: none;
width: 200px;
}
.element-left {
left: -100px;
}
.element-right {
right: -100px;
}
.element-top {
top: -30px;
}
.element-bottom {
bottom: -30px;
}
.element-left,
.element-center,
.element-right {
top: 50%;
margin-top: -30px;
}
.element-top,
.element-center,
.element-bottom {
left: 50%;
margin-left: -100px;
}
.element-image {
padding: 0;
left: 50%;
margin-left: -300px;
margin-top: -150px;
top: 50%;
width: 600px;
}
.element-image img {
max-width: 100%;
max-height: none;
display: block;
}
</style>
<script src="https://localhost:5001/scripts/test.js"></script>
</head>
<body>
<% preact.bodyEnd %>
<div class="element element-top" squidex-token="eyJhIjoic3F1aWRleC13ZWJzaXRlIiwicyI6ImJsb2ciLCJpIjoiZmQxZDkzNjYtNDY3ZS00MmFiLWE5MzYtYmNmNDk3NjIxYjk4IiwidSI6Imh0dHBzOi8vbG9jYWxob3N0OjUwMDEvIn0=">
Top Aligned
</div>
<div class="element element-right" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Right Aligned
</div>
<div class="element element-bottom" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Bottom Aligned
</div>
<div class="element element-left" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Left Aligned
</div>
<div class="element element-image">
<img src="https://localhost:5001/api/assets/test/51135569-96e0-45f2-a5c2-8102ad4b4bf3/mydraft.gif?version=0">
</div>
<div class="element element-center" squidex-token="ewogICJ1IjogImh0dHBzOi8vbG9jYWxob3N0OjUwMDEiLAogICJhIjogInRlc3QiLAogICJzIjogInRlc3QiLAogICJpIjogIjI0ZWQxMWJhLWJjNzItNDMzZS04ODRjLWE0NGE3ODIwYjU1MSIgIAp9">
Center
</div>
</body>
</html>

52
sdk/src/utils.ts

@ -0,0 +1,52 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export function isArray(value: any): value is any[] {
return Array.isArray(value);
}
export function isString(value: any): value is string {
return typeof value === 'string' || value instanceof String;
}
export function isUndefined(value: any): value is undefined {
return typeof value === 'undefined';
}
export function isBoolean(value: any): value is boolean {
return typeof value === 'boolean';
}
export function isFunction(value: any): value is Function {
return typeof value === 'function';
}
export function isNumber(value: any): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
export function isObject(value: any): value is Object {
return value && typeof value === 'object' && value.constructor === Object;
}
export function getBaseUrl() {
let url = (document.currentScript as any)?.['src'] as string;
if (!isString(url)) {
return null;
}
url = url.trim();
let indexOfHash = url.indexOf('/', 'https://'.length);
if (indexOfHash > 0) {
url = url.substring(0, indexOfHash);
}
return url;
}

16
sdk/tsconfig.json

@ -0,0 +1,16 @@
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"module": "ESNext",
"moduleResolution": "node",
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"target": "ES5"
},
"include": ["src/**/*", "tests/**/*"]
}
Loading…
Cancel
Save