Browse Source

User info (#1278)

* Menu

* Move to components.

* Show a marker for unsaved changes.

* User info field.

* User info implementation.

* Fix tests

* Update tests

* Update auth.

* Fix DB provider.

* User info

* Instructions.
pull/1283/head
Sebastian Stehle 7 months ago
committed by GitHub
parent
commit
2236874244
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs
  2. 42
      backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs
  3. 22
      backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs
  4. 22
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs
  5. 4
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs
  6. 54
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs
  7. 5
      backend/i18n/frontend_de.json
  8. 5
      backend/i18n/frontend_en.json
  9. 5
      backend/i18n/frontend_fr.json
  10. 5
      backend/i18n/frontend_it.json
  11. 5
      backend/i18n/frontend_nl.json
  12. 5
      backend/i18n/frontend_pt.json
  13. 5
      backend/i18n/frontend_zh.json
  14. 3
      backend/i18n/source/backend_en.json
  15. 5
      backend/i18n/source/frontend_en.json
  16. 12
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextBuilder.cs
  17. 88
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndex.cs
  18. 4
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexGeoEntity.cs
  19. 3
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexTextEntity.cs
  20. 40
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexUserInfoEntity.cs
  21. 1629
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260117183413_AddUserInfoIndex.Designer.cs
  22. 52
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260117183413_AddUserInfoIndex.cs
  23. 55
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs
  24. 1630
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260117183420_AddUserInfoIndex.Designer.cs
  25. 45
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260117183420_AddUserInfoIndex.cs
  26. 55
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs
  27. 1632
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260117183427_AddUserInfoIndex.Designer.cs
  28. 45
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260117183427_AddUserInfoIndex.cs
  29. 55
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs
  30. 31
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/CommandFactory.cs
  31. 3
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs
  32. 9
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoShardedTextIndex.cs
  33. 5
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs
  34. 48
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs
  35. 9
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexEntity.cs
  36. 1
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Component.cs
  37. 1
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs
  38. 16
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/UserInfoParseResult.cs
  39. 53
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/UserInfoValue.cs
  40. 18
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
  41. 6
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
  42. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs
  43. 716
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs
  44. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs
  45. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs
  46. 33
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/UserInfoFieldProperties.cs
  47. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/AddDefaultValues.cs
  48. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs
  49. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueChecker.cs
  50. 11
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs
  51. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs
  52. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs
  53. 5
      backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/FilterVisitor.cs
  54. 15
      backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs
  55. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentScriptVars.cs
  56. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs
  57. 12
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/DefaultFieldValueValidatorsFactory.cs
  58. 17
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  59. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs
  60. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs
  61. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  62. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ApiKeyQuery.cs
  63. 24
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs
  64. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs
  65. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  66. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs
  67. 5
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs
  68. 2
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs
  69. 9
      backend/src/Squidex.Shared/Texts.de.resx
  70. 9
      backend/src/Squidex.Shared/Texts.fr.resx
  71. 9
      backend/src/Squidex.Shared/Texts.it.resx
  72. 9
      backend/src/Squidex.Shared/Texts.nl.resx
  73. 9
      backend/src/Squidex.Shared/Texts.pt.resx
  74. 9
      backend/src/Squidex.Shared/Texts.resx
  75. 9
      backend/src/Squidex.Shared/Texts.zh.resx
  76. 4
      backend/src/Squidex.Web/Constants.cs
  77. 8
      backend/src/Squidex.Web/Extensions.cs
  78. 20
      backend/src/Squidex.Web/Pipeline/ApiKeyAuthenticationBuilderExtensions.cs
  79. 13
      backend/src/Squidex.Web/Pipeline/ApiKeyDefaults.cs
  80. 131
      backend/src/Squidex.Web/Pipeline/ApiKeyHandler.cs
  81. 14
      backend/src/Squidex.Web/Pipeline/ApiKeyOptions.cs
  82. 26
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  83. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs
  84. 31
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UserInfoFieldPropertiesDto.cs
  85. 3
      backend/src/Squidex/Config/Authentication/AuthenticationServices.cs
  86. 26
      backend/src/Squidex/Config/Authentication/IdentityServerServices.cs
  87. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexTests.cs
  88. 34
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/ArrayFieldTests.cs
  89. 20
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/DefaultValueFactoryTests.cs
  90. 11
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs
  91. 99
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UserInfoFieldPropertiesTests.cs
  92. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  93. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs
  94. 61
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests.cs
  95. 23838
      frontend/cache.json
  96. 4
      frontend/src/app/features/content/pages/content/editor/content-editor.component.html
  97. 6
      frontend/src/app/features/content/shared/forms/assets-editor.component.html
  98. 4
      frontend/src/app/features/content/shared/forms/assets-editor.component.scss
  99. 4
      frontend/src/app/features/content/shared/forms/component-section.component.html
  100. 2
      frontend/src/app/features/content/shared/forms/content-section.component.html

8
backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs

@ -116,6 +116,14 @@ public static class AzureIndexDefinition
{
IsFilterable = true,
},
new SimpleField("userInfoApiKey", SearchFieldDataType.String)
{
IsFilterable = true,
},
new SimpleField("userInfoField", SearchFieldDataType.String)
{
IsFilterable = true,
},
};
foreach (var (field, analyzer) in FieldAnalyzers.Values)

42
backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs

@ -123,20 +123,52 @@ public sealed class AzureTextIndex : IInitializable, ITextIndex
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
var searchFilter = $"{string.Join(" or ", schemaIds.Select(x => $"schemaId eq '{x}'"))} and {searchField} eq true";
var filter = $"{string.Join(" or ", schemaIds.Select(x => $"schemaId eq '{x}'"))} and {searchField} eq true";
return SearchAsync(result, text, filter, take, factor, ct);
return SearchAsync(result, text, searchFilter, take, factor, ct);
}
private Task SearchByAppAsync(List<(DomainId, double)> result, string text, App app, SearchScope scope, int take, double factor,
CancellationToken ct = default)
{
var searchField = GetServeField(scope);
var searchFilter = $"appId eq '{app.Id}' and {searchField} eq true";
return SearchAsync(result, text, searchFilter, take, factor, ct);
}
public async Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
var searchField = GetServeField(scope);
var searchFilter = $"appId eq '{app.Id}' and {searchField} eq true and userApiKey eq '{query.ApiKey}'";
var searchOptions = new SearchOptions
{
Filter = searchFilter,
};
var filter = $"appId eq '{app.Id}' and {searchField} eq true";
searchOptions.Select.Add("contentId");
searchOptions.Select.Add("userInfoApiKey");
searchOptions.Select.Add("userInfoRole");
searchOptions.Size = 1;
searchOptions.QueryType = SearchQueryType.Full;
var results = await searchClient.SearchAsync<SearchDocument>(searchFilter, searchOptions, ct);
await foreach (var item in results.Value.GetResultsAsync().WithCancellation(ct))
{
if (item != null)
{
var id = DomainId.Create(item.Document["contentId"].ToString()!);
return new UserInfoResult(id, item.Document["userInfoRole"].ToString()!);
}
}
return SearchAsync(result, text, filter, take, factor, ct);
return null;
}
private async Task SearchAsync(List<(DomainId, double)> result, string text, string filter, int take, double factor,

22
backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs

@ -70,6 +70,28 @@ public static class CommandFactory
}
}
if (upsert.UserInfos != null)
{
foreach (var userInfo in upsert.UserInfos)
{
var geoDocument = new SearchDocument
{
["docId"] = upsert.ToDocId(),
["appId"] = upsert.UniqueContentId.AppId.ToString(),
["appName"] = string.Empty,
["contentId"] = upsert.UniqueContentId.ContentId.ToString(),
["schemaId"] = upsert.SchemaId.Id.ToString(),
["schemaName"] = upsert.SchemaId.Name,
["serveAll"] = upsert.ServeAll,
["servePublished"] = upsert.ServePublished,
["userInfoApiKey"] = userInfo,
["userInfoRole"] = userInfo.Role,
};
batch.Add(IndexDocumentsAction.MergeOrUpload(geoDocument));
}
}
if (upsert.Texts is { Count: > 0 })
{
var document = new SearchDocument

22
backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs

@ -81,6 +81,28 @@ public static class CommandFactory
}
}
if (upsert.UserInfos != null)
{
foreach (var userInfo in upsert.UserInfos)
{
var userInfoApiKey = userInfo.ApiKey;
var userInfoRole = userInfo.Role;
AddArgs(new
{
appId = upsert.UniqueContentId.AppId.ToString(),
appName = string.Empty,
contentId = upsert.UniqueContentId.ContentId.ToString(),
schemaId = upsert.SchemaId.Id.ToString(),
schemaName = upsert.SchemaId.Name,
serveAll = upsert.ServeAll,
servePublished = upsert.ServePublished,
userInfoApiKey,
userInfoRole,
});
}
}
if (upsert.Texts is { Count: > 0 })
{
var texts = new Dictionary<string, string>();

4
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs

@ -105,6 +105,10 @@ public static class ElasticSearchIndexDefinition
{
type = "geo_point",
},
["userInfoApiKey"] = new
{
type = "text",
},
},
};

54
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs

@ -15,7 +15,8 @@ using Squidex.Infrastructure.Json;
namespace Squidex.Extensions.Text.ElasticSearch;
public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticClient, string indexName, IJsonSerializer jsonSerializer) : ITextIndex, IInitializable
public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticClient, string indexName, IJsonSerializer jsonSerializer)
: ITextIndex, IInitializable
{
private static readonly Regex RegexLanguageNormal = BuildLanguageRegexNormal();
private static readonly Regex RegexLanguageStart = BuildLanguageRegexStart();
@ -198,13 +199,62 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
return await SearchAsync(elasticQuery, ct);
}
public async Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
var serveField = GetServeField(scope);
var elasticQuery = new
{
query = new
{
@bool = new
{
filter = new object[]
{
new
{
term = new Dictionary<string, string>
{
[serveField] = "true",
},
},
new
{
term = new Dictionary<string, string>
{
["userInfoApiKey.keyword"] = query.ApiKey,
},
},
},
},
},
_source = new[]
{
"contentId",
"userInfoApiKey",
"userInfoRole",
},
size = 1,
};
var hits = await elasticClient.SearchAsync(indexName, elasticQuery, ct);
var hit = hits.FirstOrDefault();
return hit != null ?
new UserInfoResult(DomainId.Create(hit["_source"]["contentId"]), hit["_source"]["userInfoRole"]) :
null;
}
private async Task<List<DomainId>> SearchAsync(object query,
CancellationToken ct)
{
var hits = await elasticClient.SearchAsync(indexName, query, ct);
var ids = new List<DomainId>();
foreach (var item in hits)
{
ids.Add(DomainId.Create(item["_source"]["contentId"]));

5
backend/i18n/frontend_de.json

@ -198,6 +198,7 @@
"common.administration": "Verwaltung",
"common.administrationPageTitle": "Verwaltung",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "Apps",
"common.aspectRatio": "Seitenverhältnis",
"common.assets": "Assets",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Möchten Sie den Wert zurücksetzen?",
"contents.updated": "Inhalt erfolgreich aktualisiert.",
"contents.updateFailed": "Fehler beim Aktualisieren des Inhalts. Bitte neu laden.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Validieren",
"contents.validationHint": "Bitte denken Sie daran, alle Sprachen zu überprüfen, wenn Sie Validierungsfehler sehen.",
"contents.versionCompare": "Vergleichen",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Min. Elemente",
"schemas.fieldTypes.tags.description": "Spezialformat für Tags.",
"schemas.fieldTypes.ui.description": "Trennzeichen für die Bearbeitungsoberfläche.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Fehler beim Ausblenden des Feldes. Bitte neu laden.",
"schemas.import": "Schema importieren",
"schemas.indexes.addIndex": "Index hinzufügen",

5
backend/i18n/frontend_en.json

@ -198,6 +198,7 @@
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "Apps",
"common.aspectRatio": "AspectRatio",
"common.assets": "Assets",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Do you want to unset the value?",
"contents.updated": "Content updated successfully.",
"contents.updateFailed": "Failed to update content. Please reload.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Validate",
"contents.validationHint": "Please remember to check all languages when you see validation errors.",
"contents.versionCompare": "Compare",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Min Items",
"schemas.fieldTypes.tags.description": "Special format for tags.",
"schemas.fieldTypes.ui.description": "Separator for editing UI.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Failed to hide field. Please reload.",
"schemas.import": "Import schema",
"schemas.indexes.addIndex": "Add Index",

5
backend/i18n/frontend_fr.json

@ -198,6 +198,7 @@
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "applications",
"common.aspectRatio": "Ratio d'aspect",
"common.assets": "Actifs",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Voulez-vous annuler la valeur\u00A0?",
"contents.updated": "Contenu mis à jour avec succès.",
"contents.updateFailed": "Échec de la mise à jour du contenu. Veuillez recharger.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Valider",
"contents.validationHint": "N'oubliez pas de vérifier toutes les langues lorsque vous voyez des erreurs de validation.",
"contents.versionCompare": "Comparer",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Articles minimum",
"schemas.fieldTypes.tags.description": "Format spécial pour les balises.",
"schemas.fieldTypes.ui.description": "Séparateur pour l'édition de l'interface utilisateur.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Impossible de masquer le champ. Veuillez recharger.",
"schemas.import": "Calendrier d'importation",
"schemas.indexes.addIndex": "Add Index",

5
backend/i18n/frontend_it.json

@ -198,6 +198,7 @@
"common.administration": "Amministrazione",
"common.administrationPageTitle": "Amministrazione",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "App",
"common.aspectRatio": "Proporzioni",
"common.assets": "Risorse",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Sei sicuro di voler annullare il valore impostato?",
"contents.updated": "Contenuto aggiornato con successo.",
"contents.updateFailed": "Non è stato possibile aggiornare il contenuto. Per favore ricarica.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Convalida",
"contents.validationHint": "Ricorda di verificare tutte le lingue quando vedi errori di validazione.",
"contents.versionCompare": "Confronta",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Numero min di Elementi",
"schemas.fieldTypes.tags.description": "Formato speciale per i tag.",
"schemas.fieldTypes.ui.description": "Separatore per il pannello delle modifiche della UI.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Non è stato possibile nascondere il campo. Per favore ricarica.",
"schemas.import": "Importa uno schema",
"schemas.indexes.addIndex": "Add Index",

5
backend/i18n/frontend_nl.json

@ -198,6 +198,7 @@
"common.administration": "Administratie",
"common.administrationPageTitle": "Administratie",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "Apps",
"common.aspectRatio": "AspectRatio",
"common.assets": "Bestanden",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Weet je zeker dat je de waarde leeg wilt maken?",
"contents.updated": "Inhoud succesvol bijgewerkt.",
"contents.updateFailed": "Bijwerken van inhoud is mislukt. Laad opnieuw.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Valideren",
"contents.validationHint": "Denk eraan om alle talen te controleren wanneer je validatiefouten ziet.",
"contents.versionCompare": "Vergelijk",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Min. items",
"schemas.fieldTypes.tags.description": "Speciaal formaat voor tags.",
"schemas.fieldTypes.ui.description": "Scheidingsteken voor het bewerken van gebruikersinterface.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Kan veld niet verbergen. Laad opnieuw.",
"schemas.import": "Importeer schema",
"schemas.indexes.addIndex": "Add Index",

5
backend/i18n/frontend_pt.json

@ -198,6 +198,7 @@
"common.administration": "Administração",
"common.administrationPageTitle": "Administração",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "Aplicativos",
"common.aspectRatio": "AspectRatio",
"common.assets": "Ficheiros",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Quer desaparassar o valor?",
"contents.updated": "Conteúdo atualizado com sucesso.",
"contents.updateFailed": "Falhou na atualização do conteúdo. Por favor, recarregue.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Validar",
"contents.validationHint": "Por favor, lembre-se de verificar todos os idiomas quando vir erros de validação.",
"contents.versionCompare": "Comparar",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Min Itens",
"schemas.fieldTypes.tags.description": "Formato especial para tags.",
"schemas.fieldTypes.ui.description": "Separador para edição de UI.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Falhou em esconder o campo. Por favor, recarregue.",
"schemas.import": "Esquema de importação",
"schemas.indexes.addIndex": "Add Index",

5
backend/i18n/frontend_zh.json

@ -198,6 +198,7 @@
"common.administration": "管理",
"common.administrationPageTitle": "管理",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "应用程序",
"common.aspectRatio": "纵横比",
"common.assets": "资源",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "你想取消设置吗?",
"contents.updated": "内容更新成功。",
"contents.updateFailed": "更新内容失败,请重新加载。",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "验证",
"contents.validationHint": "当您看到验证错误时,请记住检查所有语言。",
"contents.versionCompare": "比较",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "最小项目",
"schemas.fieldTypes.tags.description": "标签的特殊格式。",
"schemas.fieldTypes.ui.description": "编辑 UI 的分隔符。",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "隐藏字段失败。请重新加载。",
"schemas.import": "导入Schemas",
"schemas.indexes.addIndex": "Add Index",

3
backend/i18n/source/backend_en.json

@ -139,6 +139,9 @@
"contents.invalidNumber": "Invalid json type, expected number.",
"contents.invalidRichText": "Invalid rich text.",
"contents.invalidString": "Invalid json type, expected string.",
"contents.invalidUserInfo": "Invalid json type, expected apiKey+role object.",
"contents.invalidUserInfoApiKey": "ApiKey must be defined.",
"contents.invalidUserInfoRole": "Role must be defined.",
"contents.listReferences": "{count} Reference(s)",
"contents.referenced": "Content is referenced by another content and cannot be deleted or unpublished.",
"contents.schemaNotPublished": "Schema not published.",

5
backend/i18n/source/frontend_en.json

@ -198,6 +198,7 @@
"common.administration": "Administration",
"common.administrationPageTitle": "Administration",
"common.api": "API",
"common.apiKey": "API Key",
"common.apps": "Apps",
"common.aspectRatio": "AspectRatio",
"common.assets": "Assets",
@ -523,6 +524,7 @@
"contents.unsetValueConfirmTitle": "Do you want to unset the value?",
"contents.updated": "Content updated successfully.",
"contents.updateFailed": "Failed to update content. Please reload.",
"contents.userInfo.instructions": "Connect as this user with the following header. The first part of the value is the app name.\n\nDo not miss the colon and remember the assign a restrictive role.",
"contents.validate": "Validate",
"contents.validationHint": "Please remember to check all languages when you see validation errors.",
"contents.versionCompare": "Compare",
@ -984,6 +986,9 @@
"schemas.fieldTypes.tags.countMin": "Min Items",
"schemas.fieldTypes.tags.description": "Special format for tags.",
"schemas.fieldTypes.ui.description": "Separator for editing UI.",
"schemas.fieldTypes.user.description": "User Credentials for custom Auth.",
"schemas.fieldTypes.userInfo.defaultRole": "Default Role",
"schemas.fieldTypes.userInfo.defaultRoleHint": "The default role. If a value is set a user info with a random API Key will be generated.",
"schemas.hideFieldFailed": "Failed to hide field. Please reload.",
"schemas.import": "Import schema",
"schemas.indexes.addIndex": "Add Index",

12
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextBuilder.cs

@ -25,8 +25,6 @@ public static class EFTextBuilder
builder.Entity<EFTextIndexTextEntity>(b =>
{
b.ToTable("Texts");
b.Property(x => x.Id).HasMaxLength(400);
b.Property(x => x.AppId).AsString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ContentId).AsString();
@ -34,12 +32,16 @@ public static class EFTextBuilder
builder.Entity<EFTextIndexGeoEntity>(b =>
{
b.ToTable("Geos");
b.Property(x => x.Id).HasMaxLength(400);
b.Property(x => x.AppId).AsString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ContentId).AsString();
b.Property(x => x.GeoField).HasMaxLength(255);
});
builder.Entity<EFTextIndexUserInfoEntity>(b =>
{
b.Property(x => x.AppId).AsString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ContentId).AsString();
});
}
}

88
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndex.cs

@ -53,6 +53,9 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
await dbContext.Set<EFTextIndexGeoEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexUserInfoEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
}
async Task IDeleter.DeleteSchemaAsync(App app, Schema schema,
@ -65,6 +68,9 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
await dbContext.Set<EFTextIndexGeoEntity>().Where(x => x.AppId == app.Id && x.SchemaId == schema.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexUserInfoEntity>().Where(x => x.AppId == app.Id && x.SchemaId == schema.Id)
.ExecuteDeleteAsync(ct);
}
public async Task ClearAsync(CancellationToken ct = default)
@ -91,15 +97,19 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
SRID = 4326,
};
// The distance must be converted to decrees (in contrast to MongoDB, which uses radian).
var degrees = query.Radius / 111320;
var distance = query.Radius;
if (dbContext.Database.IsNpgsql())
{
// The distance must be converted to decrees (in contrast to MongoDB, which uses radian).
distance = query.Radius / 111320;
}
var ids =
await dbContext.Set<EFTextIndexGeoEntity>()
.Where(x => x.AppId == app.Id)
.Where(x => x.SchemaId == query.SchemaId)
.Where(x => x.GeoField == query.Field)
.Where(x => x.GeoObject.Distance(point) < degrees)
.Where(x => x.GeoObject.Distance(point) < distance)
.WhereScope(scope)
.Select(x => x.ContentId)
.ToListAsync(ct);
@ -151,38 +161,63 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
return search.Results.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
}
private static Task SearchBySchemaAsync(TContext context, SearchOperation search, IEnumerable<DomainId> schemaIds, double factor,
public async Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
await using var dbContext = await CreateDbContextAsync(ct);
var queryBuilder =
context.Query<EFTextIndexTextEntity>()
dbContext.Query<EFTextIndexUserInfoEntity>()
.Where(ClrFilter.Eq("AppId", app.Id))
.Where(ClrFilter.Eq("UserInfoApiKey", query.ApiKey))
.WhereScope(scope);
var (sql, parameters) = queryBuilder.Compile();
var entity =
await dbContext.Set<EFTextIndexUserInfoEntity>().FromSqlRaw(sql, parameters)
.FirstOrDefaultAsync(ct);
return entity != null ?
new UserInfoResult(entity.ContentId, entity.UserInfoRole) :
null;
}
private static Task SearchBySchemaAsync(TContext dbContext, SearchOperation search, IEnumerable<DomainId> schemaIds, double factor,
CancellationToken ct = default)
{
var queryBuilder =
dbContext.Query<EFTextIndexTextEntity>()
.Where(ClrFilter.Eq("AppId", search.App.Id))
.Where(ClrFilter.In("SchemaId", schemaIds.ToList()))
.WhereMatch("Texts", search.SearchTerms)
.WhereScope(search.SearchScope);
return SearchAsync(context, search, queryBuilder, factor, ct);
return SearchAsync(dbContext, search, queryBuilder, factor, ct);
}
private static Task SearchByAppAsync(TContext context, SearchOperation search, double factor,
private static Task SearchByAppAsync(TContext dbContext, SearchOperation search, double factor,
CancellationToken ct = default)
{
var queryBuilder =
context.Query<EFTextIndexTextEntity>()
dbContext.Query<EFTextIndexTextEntity>()
.Where(ClrFilter.Eq("AppId", search.App.Id))
.WhereMatch("Texts", search.SearchTerms)
.WhereScope(search.SearchScope);
return SearchAsync(context, search, queryBuilder, factor, ct);
return SearchAsync(dbContext, search, queryBuilder, factor, ct);
}
private static async Task SearchAsync(TContext context, SearchOperation search, SqlQueryBuilder queryBuilder, double factor,
private static async Task SearchAsync(TContext dbContext, SearchOperation search, SqlQueryBuilder queryBuilder, double factor,
CancellationToken ct)
{
var (sql, parameters) = queryBuilder.Compile();
var ids =
await context.Set<EFTextIndexTextEntity>().FromSqlRaw(sql, parameters)
await dbContext.Set<EFTextIndexTextEntity>().FromSqlRaw(sql, parameters)
.Select(x => x.ContentId)
.ToListAsync(ct);
@ -196,6 +231,7 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
var insertsText = new List<EFTextIndexTextEntity>();
var insertsGeo = new List<EFTextIndexGeoEntity>();
var insertsUser = new List<EFTextIndexUserInfoEntity>();
foreach (var batch in commands.Batch(1000))
{
@ -264,6 +300,24 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
}
}
foreach (var userInfo in upsert.UserInfos.OrEmpty())
{
var entity = new EFTextIndexUserInfoEntity
{
Id = id,
AppId = appId,
ContentId = contentId,
UserInfoApiKey = userInfo.ApiKey,
UserInfoRole = userInfo.Role,
SchemaId = upsert.SchemaId.Id,
ServeAll = upsert.ServeAll,
ServePublished = upsert.ServePublished,
Stage = upsert.Stage,
};
insertsUser.Add(entity);
}
break;
case DeleteIndexEntry:
await dbContext.Set<EFTextIndexTextEntity>()
@ -273,6 +327,10 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
await dbContext.Set<EFTextIndexGeoEntity>()
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexUserInfoEntity>()
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
break;
case UpdateIndexEntry update:
await dbContext.Set<EFTextIndexTextEntity>()
@ -288,6 +346,13 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
.SetProperty(x => x.ServeAll, update.ServeAll)
.SetProperty(x => x.ServePublished, update.ServePublished),
ct);
await dbContext.Set<EFTextIndexUserInfoEntity>()
.Where(x => x.Id == id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.ServeAll, update.ServeAll)
.SetProperty(x => x.ServePublished, update.ServePublished),
ct);
break;
}
}
@ -295,6 +360,7 @@ public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextF
await dbContext.BulkUpsertAsync(insertsText, ct);
await dbContext.BulkUpsertAsync(insertsGeo, ct);
await dbContext.BulkUpsertAsync(insertsUser, ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)

4
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexGeoEntity.cs

@ -6,14 +6,17 @@
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NetTopologySuite.Geometries;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Table("Geos")]
public sealed class EFTextIndexGeoEntity
{
[Key]
[MaxLength(400)]
required public string Id { get; set; }
public DomainId AppId { get; set; }
@ -28,6 +31,7 @@ public sealed class EFTextIndexGeoEntity
public bool ServePublished { get; set; }
[MaxLength(255)]
public string GeoField { get; set; }
public Geometry GeoObject { get; set; }

3
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexTextEntity.cs

@ -6,13 +6,16 @@
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Table("Texts")]
public sealed class EFTextIndexTextEntity
{
[Key]
[MaxLength(400)]
required public string Id { get; set; }
public DomainId AppId { get; set; }

40
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexUserInfoEntity.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Table("UserInfos")]
[Index(nameof(UserInfoApiKey))]
public sealed class EFTextIndexUserInfoEntity
{
[Key]
[MaxLength(400)]
required public string Id { get; set; }
public DomainId AppId { get; set; }
public DomainId SchemaId { get; set; }
public DomainId ContentId { get; set; }
public byte Stage { get; set; }
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }
[MaxLength(256)]
public string UserInfoApiKey { get; set; }
[MaxLength(256)]
public string UserInfoRole { get; set; }
}

1629
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260117183413_AddUserInfoIndex.Designer.cs

File diff suppressed because it is too large

52
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20260117183413_AddUserInfoIndex.cs

@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.MySql.App.Migrations
{
/// <inheritdoc />
public partial class AddUserInfoIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserInfos",
columns: table => new
{
Id = table.Column<string>(type: "varchar(400)", maxLength: 400, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AppId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SchemaId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContentId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Stage = table.Column<byte>(type: "tinyint unsigned", nullable: false),
ServeAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
ServePublished = table.Column<bool>(type: "tinyint(1)", nullable: false),
UserInfoApiKey = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UserInfoRole = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_UserInfos", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_UserInfos_UserInfoApiKey",
table: "UserInfos",
column: "UserInfoApiKey");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInfos");
}
}
}

55
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs

@ -18,7 +18,7 @@ namespace Squidex.Providers.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.14")
.HasAnnotation("ProductVersion", "8.0.16")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -845,7 +845,7 @@ namespace Squidex.Providers.MySql.Migrations
b.HasKey("Id");
b.ToTable("Geos", (string)null);
b.ToTable("Geos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
@ -884,7 +884,54 @@ namespace Squidex.Providers.MySql.Migrations
b.HasKey("Id");
b.ToTable("Texts", (string)null);
b.ToTable("Texts");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("varchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("tinyint(1)");
b.Property<bool>("ServePublished")
.HasColumnType("tinyint(1)");
b.Property<byte>("Stage")
.HasColumnType("tinyint unsigned");
b.Property<string>("UserInfoApiKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("UserInfoRole")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("UserInfoApiKey");
b.ToTable("UserInfos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
@ -1046,7 +1093,6 @@ namespace Squidex.Providers.MySql.Migrations
modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("EventStream")
@ -1118,7 +1164,6 @@ namespace Squidex.Providers.MySql.Migrations
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTimeOffset>("Created")

1630
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260117183420_AddUserInfoIndex.Designer.cs

File diff suppressed because it is too large

45
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20260117183420_AddUserInfoIndex.cs

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.Postgres.App.Migrations
{
/// <inheritdoc />
public partial class AddUserInfoIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserInfos",
columns: table => new
{
Id = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "smallint", nullable: false),
ServeAll = table.Column<bool>(type: "boolean", nullable: false),
ServePublished = table.Column<bool>(type: "boolean", nullable: false),
UserInfoApiKey = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
UserInfoRole = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInfos", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_UserInfos_UserInfoApiKey",
table: "UserInfos",
column: "UserInfoApiKey");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInfos");
}
}
}

55
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs

@ -18,7 +18,7 @@ namespace Squidex.Providers.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.14")
.HasAnnotation("ProductVersion", "8.0.16")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
@ -846,7 +846,7 @@ namespace Squidex.Providers.Postgres.Migrations
b.HasKey("Id");
b.ToTable("Geos", (string)null);
b.ToTable("Geos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
@ -885,7 +885,54 @@ namespace Squidex.Providers.Postgres.Migrations
b.HasKey("Id");
b.ToTable("Texts", (string)null);
b.ToTable("Texts");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("ServeAll")
.HasColumnType("boolean");
b.Property<bool>("ServePublished")
.HasColumnType("boolean");
b.Property<byte>("Stage")
.HasColumnType("smallint");
b.Property<string>("UserInfoApiKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserInfoRole")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("UserInfoApiKey");
b.ToTable("UserInfos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
@ -1047,7 +1094,6 @@ namespace Squidex.Providers.Postgres.Migrations
modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("EventStream")
@ -1119,7 +1165,6 @@ namespace Squidex.Providers.Postgres.Migrations
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("Created")

1632
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260117183427_AddUserInfoIndex.Designer.cs

File diff suppressed because it is too large

45
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20260117183427_AddUserInfoIndex.cs

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.SqlServer.App.Migrations
{
/// <inheritdoc />
public partial class AddUserInfoIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserInfos",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "tinyint", nullable: false),
ServeAll = table.Column<bool>(type: "bit", nullable: false),
ServePublished = table.Column<bool>(type: "bit", nullable: false),
UserInfoApiKey = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
UserInfoRole = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInfos", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_UserInfos_UserInfoApiKey",
table: "UserInfos",
column: "UserInfoApiKey");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInfos");
}
}
}

55
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs

@ -18,7 +18,7 @@ namespace Squidex.Providers.SqlServer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.14")
.HasAnnotation("ProductVersion", "8.0.16")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@ -848,7 +848,7 @@ namespace Squidex.Providers.SqlServer.Migrations
b.HasKey("Id");
b.ToTable("Geos", (string)null);
b.ToTable("Geos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
@ -887,7 +887,54 @@ namespace Squidex.Providers.SqlServer.Migrations
b.HasKey("Id");
b.ToTable("Texts", (string)null);
b.ToTable("Texts");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexUserInfoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("bit");
b.Property<bool>("ServePublished")
.HasColumnType("bit");
b.Property<byte>("Stage")
.HasColumnType("tinyint");
b.Property<string>("UserInfoApiKey")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("UserInfoRole")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("UserInfoApiKey");
b.ToTable("UserInfos");
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
@ -1049,7 +1096,6 @@ namespace Squidex.Providers.SqlServer.Migrations
modelBuilder.Entity("Squidex.Events.EntityFramework.EFEventCommit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("EventStream")
@ -1121,7 +1167,6 @@ namespace Squidex.Providers.SqlServer.Migrations
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("Created")

31
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/CommandFactory.cs

@ -80,6 +80,37 @@ public sealed class CommandFactory<T>(Func<Dictionary<string, string>, T> textBu
}));
}
}
if (upsert.UserInfos?.Count > 0)
{
if (!upsert.IsNew)
{
writes.Add(
new DeleteManyModel<MongoTextIndexEntity<T>>(
Filter.And(
FilterByCommand(upsert),
Filter.Exists(x => x.UserInfoApiKey),
Filter.Exists(x => x.UserInfoRole))));
}
foreach (var userInfo in upsert.UserInfos)
{
writes.Add(
new InsertOneModel<MongoTextIndexEntity<T>>(
new MongoTextIndexEntity<T>
{
Id = ObjectId.GenerateNewId(),
AppId = upsert.UniqueContentId.AppId,
ContentId = upsert.UniqueContentId.ContentId,
UserInfoApiKey = userInfo.ApiKey,
UserInfoRole = userInfo.Role,
SchemaId = upsert.SchemaId.Id,
ServeAll = upsert.ServeAll,
ServePublished = upsert.ServePublished,
Stage = upsert.Stage,
}));
}
}
}
private T? BuildTexts(UpsertIndexEntry upsert)

3
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/DocumentDbTextIndex.cs

@ -14,7 +14,8 @@ using Squidex.Infrastructure.ObjectPool;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class DocumentDbTextIndex(IMongoDatabase database, string shardKey)
: MongoTextIndexBase<string>(database, shardKey, new CommandFactory<string>(BuildTexts))
: MongoTextIndexBase<string>(database, shardKey,
new CommandFactory<string>(BuildTexts))
{
private record struct SearchOperation
{

9
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoShardedTextIndex.cs

@ -12,7 +12,8 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class MongoShardedTextIndex<T>(IShardingStrategy sharding, Func<string, MongoTextIndexBase<T>> factory) : ShardedService<DomainId, MongoTextIndexBase<T>>(sharding, factory), ITextIndex, IDeleter where T : class
public sealed class MongoShardedTextIndex<T>(IShardingStrategy sharding, Func<string, MongoTextIndexBase<T>> factory)
: ShardedService<DomainId, MongoTextIndexBase<T>>(sharding, factory), ITextIndex, IDeleter where T : class
{
public async Task ClearAsync(
CancellationToken ct = default)
@ -45,6 +46,12 @@ public sealed class MongoShardedTextIndex<T>(IShardingStrategy sharding, Func<st
return Shard(app.Id).SearchAsync(app, query, scope, ct);
}
public Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default)
{
return Shard(app.Id).FindUserInfo(app, query, scope, ct);
}
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{

5
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs

@ -11,8 +11,9 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class MongoTextIndex(IMongoDatabase database, string shardKey)
: MongoTextIndexBase<List<MongoTextIndexEntityText>>(database, shardKey, new CommandFactory<List<MongoTextIndexEntityText>>(BuildTexts))
public sealed class MongoTextIndex(IMongoDatabase database, string shardKey)
: MongoTextIndexBase<List<MongoTextIndexEntityText>>(database, shardKey,
new CommandFactory<List<MongoTextIndexEntityText>>(BuildTexts))
{
private record struct SearchOperation
{

48
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs

@ -14,7 +14,8 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shardKey, CommandFactory<T> factory) : MongoRepositoryBase<MongoTextIndexEntity<T>>(database), ITextIndex, IDeleter where T : class
public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shardKey, CommandFactory<T> factory)
: MongoRepositoryBase<MongoTextIndexEntity<T>>(database), ITextIndex, IDeleter where T : class
{
protected sealed class MongoTextResult
{
@ -31,6 +32,25 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
public double Score { get; set; }
}
protected sealed class MongoApiKeyResult
{
[BsonId]
[BsonElement]
public ObjectId Id { get; set; }
[BsonRequired]
[BsonElement("c")]
public DomainId ContentId { get; set; }
[BsonIgnoreIfNull]
[BsonElement("k")]
public string UserInfoApiKey { get; set; }
[BsonIgnoreIfNull]
[BsonElement("r")]
public string UserInfoRole { get; set; }
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoTextIndexEntity<T>> collection,
CancellationToken ct)
{
@ -54,6 +74,11 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
new CreateIndexModel<MongoTextIndexEntity<T>>(
Index
.Geo2DSphere(x => x.GeoObject)),
new CreateIndexModel<MongoTextIndexEntity<T>>(
Index
.Geo2DSphere(x => x.UserInfoApiKey),
new CreateIndexOptions { Sparse = true }),
], ct);
}
else
@ -123,6 +148,27 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
}
}
public async Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
// Use the filter in the correct order to leverage the index in the best way.
var findFilter =
Filter.And(
Filter.Eq(x => x.AppId, app.Id),
Filter.Eq(x => x.UserInfoApiKey, query.ApiKey),
FilterByScope(scope));
var byApiKey =
await GetCollection(scope).Find(findFilter).Limit(1)
.Project<MongoApiKeyResult>(Projection.Include(x => x.ContentId).Include(x => x.UserInfoRole))
.FirstOrDefaultAsync(ct);
return byApiKey != null ? new UserInfoResult(byApiKey.ContentId, byApiKey.UserInfoRole) : null;
}
public virtual async Task<List<DomainId>?> SearchAsync(App app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{

9
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexEntity.cs

@ -8,6 +8,7 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using NetTopologySuite.Geometries;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
@ -49,6 +50,14 @@ public sealed class MongoTextIndexEntity<T>
[BsonElement("t")]
public T Texts { get; set; }
[BsonIgnoreIfNull]
[BsonElement("k")]
public string? UserInfoApiKey { get; set; }
[BsonIgnoreIfNull]
[BsonElement("r")]
public string? UserInfoRole { get; set; }
[BsonIgnoreIfNull]
[BsonElement("g")]
public string GeoField { get; set; }

1
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Component.cs

@ -53,7 +53,6 @@ public sealed record Component(string Type, JsonObject Data, Schema Schema)
}
discriminator = s;
return true;
}
}

1
backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs

@ -41,7 +41,6 @@ public static class GeoJsonValue
}
geoJSON = new Point(new Coordinate(lon, lat));
return GeoJsonParseResult.Success;
}

16
backend/src/Squidex.Domain.Apps.Core.Model/Contents/UserInfoParseResult.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Contents;
public enum UserInfoParseResult
{
Success,
InvalidApiKey,
InvalidRole,
InvalidValue,
}

53
backend/src/Squidex.Domain.Apps.Core.Model/Contents/UserInfoValue.cs

@ -0,0 +1,53 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Core.Contents;
public sealed record UserInfoValue(string ApiKey, string Role)
{
public static JsonValue CreateDefault(string role)
{
var apiKey =
Guid.NewGuid().ToString().ToSha256Base64()
.Replace("+", string.Empty, StringComparison.Ordinal)
.Replace("/", string.Empty, StringComparison.Ordinal)
.TrimEnd('=');
return JsonValue.Object().Add("apiKey", apiKey).Add("role", role);
}
public static UserInfoParseResult TryParse(JsonValue value, out UserInfoValue? userInfo)
{
Guard.NotNull(value);
userInfo = null;
if (value.Value is JsonObject o)
{
if (!o.TryGetValue("apiKey", out var found) || found.Value is not string apiKey || string.IsNullOrWhiteSpace(apiKey))
{
return UserInfoParseResult.InvalidApiKey;
}
if (!o.TryGetValue("role", out found) || found.Value is not string role || string.IsNullOrWhiteSpace(role))
{
return UserInfoParseResult.InvalidRole;
}
userInfo = new UserInfoValue(apiKey, role);
return UserInfoParseResult.Success;
}
return UserInfoParseResult.InvalidValue;
}
}

18
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs

@ -1131,6 +1131,24 @@ namespace Squidex.Domain.Apps.Core {
}
}
/// <summary>
/// Looks up a localized string similar to The API key for authentication..
/// </summary>
public static string UserInfoApiKey {
get {
return ResourceManager.GetString("UserInfoApiKey", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The user role..
/// </summary>
public static string UserInfoRole {
get {
return ResourceManager.GetString("UserInfoRole", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to True when this user is a client, which is typically the case when the request is made from the API..
/// </summary>

6
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx

@ -483,4 +483,10 @@
<data name="FieldRuleAction" xml:space="preserve">
<value>The type of action that is performed.</value>
</data>
<data name="UserInfoApiKey" xml:space="preserve">
<value>The API key for authentication.</value>
</data>
<data name="UserInfoRole" xml:space="preserve">
<value>The user role.</value>
</data>
</root>

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs

@ -45,7 +45,7 @@ public sealed record ArrayField : RootField<ArrayFieldProperties>, IArrayField
}
[Pure]
public ArrayField AddField(NestedField field)
public ArrayField AddUserInfo(NestedField field)
{
return Updatefields(f => f.Add(field));
}

716
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs

@ -11,170 +11,452 @@ namespace Squidex.Domain.Apps.Core.Schemas;
public static class Fields
{
public static ArrayField Array(long id, string name, Partitioning partitioning,
ArrayFieldProperties? properties = null, params NestedField[] fields)
public static ArrayField Array(
long id,
string name,
Partitioning partitioning,
ArrayFieldProperties? properties = null,
params NestedField[] fields
)
{
return new ArrayField
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
FieldCollection = new FieldCollection<NestedField>(fields),
};
}
public static RootField<AssetsFieldProperties> Assets(
long id,
string name,
Partitioning partitioning,
AssetsFieldProperties? properties = null
)
{
return new RootField<AssetsFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<BooleanFieldProperties> Boolean(
long id,
string name,
Partitioning partitioning,
BooleanFieldProperties? properties = null
)
{
return new ArrayField { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new(), FieldCollection = new FieldCollection<NestedField>(fields) };
return new RootField<BooleanFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<AssetsFieldProperties> Assets(long id, string name, Partitioning partitioning,
AssetsFieldProperties? properties = null)
public static RootField<ComponentFieldProperties> Component(
long id,
string name,
Partitioning partitioning,
ComponentFieldProperties? properties = null
)
{
return new RootField<AssetsFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<ComponentFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<BooleanFieldProperties> Boolean(long id, string name, Partitioning partitioning,
BooleanFieldProperties? properties = null)
public static RootField<ComponentsFieldProperties> Components(
long id,
string name,
Partitioning partitioning,
ComponentsFieldProperties? properties = null
)
{
return new RootField<BooleanFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<ComponentsFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<ComponentFieldProperties> Component(long id, string name, Partitioning partitioning,
ComponentFieldProperties? properties = null)
public static RootField<DateTimeFieldProperties> DateTime(
long id,
string name,
Partitioning partitioning,
DateTimeFieldProperties? properties = null
)
{
return new RootField<ComponentFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<DateTimeFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<ComponentsFieldProperties> Components(long id, string name, Partitioning partitioning,
ComponentsFieldProperties? properties = null)
public static RootField<GeolocationFieldProperties> Geolocation(
long id,
string name,
Partitioning partitioning,
GeolocationFieldProperties? properties = null
)
{
return new RootField<ComponentsFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<GeolocationFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<DateTimeFieldProperties> DateTime(long id, string name, Partitioning partitioning,
DateTimeFieldProperties? properties = null)
public static RootField<JsonFieldProperties> Json(
long id,
string name,
Partitioning partitioning,
JsonFieldProperties? properties = null
)
{
return new RootField<DateTimeFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<JsonFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<GeolocationFieldProperties> Geolocation(long id, string name, Partitioning partitioning,
GeolocationFieldProperties? properties = null)
public static RootField<NumberFieldProperties> Number(
long id,
string name,
Partitioning partitioning,
NumberFieldProperties? properties = null
)
{
return new RootField<GeolocationFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<NumberFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<JsonFieldProperties> Json(long id, string name, Partitioning partitioning,
JsonFieldProperties? properties = null)
public static RootField<ReferencesFieldProperties> References(
long id,
string name,
Partitioning partitioning,
ReferencesFieldProperties? properties = null
)
{
return new RootField<JsonFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<ReferencesFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<NumberFieldProperties> Number(long id, string name, Partitioning partitioning,
NumberFieldProperties? properties = null)
public static RootField<RichTextFieldProperties> RichText(
long id,
string name,
Partitioning partitioning,
RichTextFieldProperties? properties = null
)
{
return new RootField<NumberFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<RichTextFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<ReferencesFieldProperties> References(long id, string name, Partitioning partitioning,
ReferencesFieldProperties? properties = null)
public static RootField<StringFieldProperties> String(
long id,
string name,
Partitioning partitioning,
StringFieldProperties? properties = null
)
{
return new RootField<ReferencesFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<StringFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<RichTextFieldProperties> RichText(long id, string name, Partitioning partitioning,
RichTextFieldProperties? properties = null)
public static RootField<TagsFieldProperties> Tags(
long id,
string name,
Partitioning partitioning,
TagsFieldProperties? properties = null
)
{
return new RootField<RichTextFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<TagsFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<StringFieldProperties> String(long id, string name, Partitioning partitioning,
StringFieldProperties? properties = null)
public static RootField<UIFieldProperties> UI(
long id,
string name,
Partitioning partitioning,
UIFieldProperties? properties = null
)
{
return new RootField<StringFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<UIFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<TagsFieldProperties> Tags(long id, string name, Partitioning partitioning,
TagsFieldProperties? properties = null)
public static RootField<UserInfoFieldProperties> UserInfo(
long id,
string name,
Partitioning partitioning,
UserInfoFieldProperties? properties = null
)
{
return new RootField<TagsFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new RootField<UserInfoFieldProperties>
{
Id = id,
Name = name,
Partitioning = partitioning,
Properties = properties ?? new(),
};
}
public static RootField<UIFieldProperties> UI(long id, string name, Partitioning partitioning,
UIFieldProperties? properties = null)
public static NestedField<AssetsFieldProperties> Assets(
long id,
string name,
AssetsFieldProperties? properties = null
)
{
return new RootField<UIFieldProperties> { Id = id, Name = name, Partitioning = partitioning, Properties = properties ?? new() };
return new NestedField<AssetsFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<AssetsFieldProperties> Assets(long id, string name,
AssetsFieldProperties? properties = null)
public static NestedField<BooleanFieldProperties> Boolean(
long id,
string name,
BooleanFieldProperties? properties = null
)
{
return new NestedField<AssetsFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<BooleanFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<BooleanFieldProperties> Boolean(long id, string name,
BooleanFieldProperties? properties = null)
public static NestedField<ComponentFieldProperties> Component(
long id,
string name,
ComponentFieldProperties? properties = null
)
{
return new NestedField<BooleanFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<ComponentFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<ComponentFieldProperties> Component(long id, string name,
ComponentFieldProperties? properties = null)
public static NestedField<ComponentsFieldProperties> Components(
long id,
string name,
ComponentsFieldProperties? properties = null
)
{
return new NestedField<ComponentFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<ComponentsFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<ComponentsFieldProperties> Components(long id, string name,
ComponentsFieldProperties? properties = null)
public static NestedField<DateTimeFieldProperties> DateTime(
long id,
string name,
DateTimeFieldProperties? properties = null
)
{
return new NestedField<ComponentsFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<DateTimeFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<DateTimeFieldProperties> DateTime(long id, string name,
DateTimeFieldProperties? properties = null)
public static NestedField<GeolocationFieldProperties> Geolocation(
long id,
string name,
GeolocationFieldProperties? properties = null
)
{
return new NestedField<DateTimeFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<GeolocationFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<GeolocationFieldProperties> Geolocation(long id, string name,
GeolocationFieldProperties? properties = null)
public static NestedField<JsonFieldProperties> Json(
long id,
string name,
JsonFieldProperties? properties = null
)
{
return new NestedField<GeolocationFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<JsonFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<JsonFieldProperties> Json(long id, string name,
JsonFieldProperties? properties = null)
public static NestedField<NumberFieldProperties> Number(
long id,
string name,
NumberFieldProperties? properties = null
)
{
return new NestedField<JsonFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<NumberFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<NumberFieldProperties> Number(long id, string name,
NumberFieldProperties? properties = null)
public static NestedField<ReferencesFieldProperties> References(
long id,
string name,
ReferencesFieldProperties? properties = null
)
{
return new NestedField<NumberFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<ReferencesFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<ReferencesFieldProperties> References(long id, string name,
ReferencesFieldProperties? properties = null)
public static NestedField<RichTextFieldProperties> RichText(
long id,
string name,
RichTextFieldProperties? properties = null
)
{
return new NestedField<ReferencesFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<RichTextFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<RichTextFieldProperties> RichText(long id, string name,
RichTextFieldProperties? properties = null)
public static NestedField<StringFieldProperties> String(
long id,
string name,
StringFieldProperties? properties = null
)
{
return new NestedField<RichTextFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<StringFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<StringFieldProperties> String(long id, string name,
StringFieldProperties? properties = null)
public static NestedField<TagsFieldProperties> Tags(
long id,
string name,
TagsFieldProperties? properties = null
)
{
return new NestedField<StringFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<TagsFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<TagsFieldProperties> Tags(long id, string name,
TagsFieldProperties? properties = null)
public static NestedField<UIFieldProperties> UI(
long id,
string name,
UIFieldProperties? properties = null
)
{
return new NestedField<TagsFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<UIFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static NestedField<UIFieldProperties> UI(long id, string name,
UIFieldProperties? properties = null)
public static NestedField<UserInfoFieldProperties> User(
long id,
string name,
UserInfoFieldProperties? properties = null
)
{
return new NestedField<UIFieldProperties> { Id = id, Name = name, Properties = properties ?? new() };
return new NestedField<UserInfoFieldProperties>
{
Id = id,
Name = name,
Properties = properties ?? new(),
};
}
public static Schema AddArray(this Schema schema, long id, string name, Partitioning partitioning,
Func<ArrayField, ArrayField>? handler = null, ArrayFieldProperties? properties = null)
public static Schema AddArray(
this Schema schema,
long id,
string name,
Partitioning partitioning,
Func<ArrayField, ArrayField>? handler = null,
ArrayFieldProperties? properties = null
)
{
var field = Array(id, name, partitioning, properties);
@ -186,159 +468,297 @@ public static class Fields
return schema.AddField(field);
}
public static Schema AddAssets(this Schema schema, long id, string name, Partitioning partitioning,
AssetsFieldProperties? properties = null)
public static Schema AddAssets(
this Schema schema,
long id,
string name,
Partitioning partitioning,
AssetsFieldProperties? properties = null
)
{
return schema.AddField(Assets(id, name, partitioning, properties));
}
public static Schema AddBoolean(this Schema schema, long id, string name, Partitioning partitioning,
BooleanFieldProperties? properties = null)
public static Schema AddBoolean(
this Schema schema,
long id,
string name,
Partitioning partitioning,
BooleanFieldProperties? properties = null
)
{
return schema.AddField(Boolean(id, name, partitioning, properties));
}
public static Schema AddComponent(this Schema schema, long id, string name, Partitioning partitioning,
ComponentFieldProperties? properties = null)
public static Schema AddComponent(
this Schema schema,
long id,
string name,
Partitioning partitioning,
ComponentFieldProperties? properties = null
)
{
return schema.AddField(Component(id, name, partitioning, properties));
}
public static Schema AddComponents(this Schema schema, long id, string name, Partitioning partitioning,
ComponentsFieldProperties? properties = null)
public static Schema AddComponents(
this Schema schema,
long id,
string name,
Partitioning partitioning,
ComponentsFieldProperties? properties = null
)
{
return schema.AddField(Components(id, name, partitioning, properties));
}
public static Schema AddDateTime(this Schema schema, long id, string name, Partitioning partitioning,
DateTimeFieldProperties? properties = null)
public static Schema AddDateTime(
this Schema schema,
long id,
string name,
Partitioning partitioning,
DateTimeFieldProperties? properties = null
)
{
return schema.AddField(DateTime(id, name, partitioning, properties));
}
public static Schema AddGeolocation(this Schema schema, long id, string name, Partitioning partitioning,
GeolocationFieldProperties? properties = null)
public static Schema AddGeolocation(
this Schema schema,
long id,
string name,
Partitioning partitioning,
GeolocationFieldProperties? properties = null
)
{
return schema.AddField(Geolocation(id, name, partitioning, properties));
}
public static Schema AddJson(this Schema schema, long id, string name, Partitioning partitioning,
JsonFieldProperties? properties = null)
public static Schema AddJson(
this Schema schema,
long id,
string name,
Partitioning partitioning,
JsonFieldProperties? properties = null
)
{
return schema.AddField(Json(id, name, partitioning, properties));
}
public static Schema AddNumber(this Schema schema, long id, string name, Partitioning partitioning,
NumberFieldProperties? properties = null)
public static Schema AddNumber(
this Schema schema,
long id,
string name,
Partitioning partitioning,
NumberFieldProperties? properties = null
)
{
return schema.AddField(Number(id, name, partitioning, properties));
}
public static Schema AddReferences(this Schema schema, long id, string name, Partitioning partitioning,
ReferencesFieldProperties? properties = null)
public static Schema AddReferences(
this Schema schema,
long id,
string name,
Partitioning partitioning,
ReferencesFieldProperties? properties = null
)
{
return schema.AddField(References(id, name, partitioning, properties));
}
public static Schema AddRichText(this Schema schema, long id, string name, Partitioning partitioning,
RichTextFieldProperties? properties = null)
public static Schema AddRichText(
this Schema schema,
long id,
string name,
Partitioning partitioning,
RichTextFieldProperties? properties = null
)
{
return schema.AddField(RichText(id, name, partitioning, properties));
}
public static Schema AddString(this Schema schema, long id, string name, Partitioning partitioning,
StringFieldProperties? properties = null)
public static Schema AddString(
this Schema schema,
long id,
string name,
Partitioning partitioning,
StringFieldProperties? properties = null
)
{
return schema.AddField(String(id, name, partitioning, properties));
}
public static Schema AddTags(this Schema schema, long id, string name, Partitioning partitioning,
TagsFieldProperties? properties = null)
public static Schema AddTags(
this Schema schema,
long id,
string name,
Partitioning partitioning,
TagsFieldProperties? properties = null
)
{
return schema.AddField(Tags(id, name, partitioning, properties));
}
public static Schema AddUI(this Schema schema, long id, string name, Partitioning partitioning,
UIFieldProperties? properties = null)
public static Schema AddUI(
this Schema schema,
long id,
string name,
Partitioning partitioning,
UIFieldProperties? properties = null
)
{
return schema.AddField(UI(id, name, partitioning, properties));
}
public static ArrayField AddAssets(this ArrayField field, long id, string name,
AssetsFieldProperties? properties = null)
public static Schema AddUserInfo(
this Schema schema,
long id,
string name,
Partitioning partitioning,
UserInfoFieldProperties? properties = null
)
{
return schema.AddField(UserInfo(id, name, partitioning, properties));
}
public static ArrayField AddAssets(
this ArrayField field,
long id,
string name,
AssetsFieldProperties? properties = null
)
{
return field.AddUserInfo(Assets(id, name, properties));
}
public static ArrayField AddBoolean(
this ArrayField field,
long id,
string name,
BooleanFieldProperties? properties = null
)
{
return field.AddField(Assets(id, name, properties));
return field.AddUserInfo(Boolean(id, name, properties));
}
public static ArrayField AddBoolean(this ArrayField field, long id, string name,
BooleanFieldProperties? properties = null)
public static ArrayField AddComponent(
this ArrayField field,
long id,
string name,
ComponentFieldProperties? properties = null
)
{
return field.AddField(Boolean(id, name, properties));
return field.AddUserInfo(Component(id, name, properties));
}
public static ArrayField AddComponent(this ArrayField field, long id, string name,
ComponentFieldProperties? properties = null)
public static ArrayField AddComponents(
this ArrayField field,
long id,
string name,
ComponentsFieldProperties? properties = null
)
{
return field.AddField(Component(id, name, properties));
return field.AddUserInfo(Components(id, name, properties));
}
public static ArrayField AddComponents(this ArrayField field, long id, string name,
ComponentsFieldProperties? properties = null)
public static ArrayField AddDateTime(
this ArrayField field,
long id,
string name,
DateTimeFieldProperties? properties = null
)
{
return field.AddField(Components(id, name, properties));
return field.AddUserInfo(DateTime(id, name, properties));
}
public static ArrayField AddDateTime(this ArrayField field, long id, string name,
DateTimeFieldProperties? properties = null)
public static ArrayField AddGeolocation(
this ArrayField field,
long id,
string name,
GeolocationFieldProperties? properties = null
)
{
return field.AddField(DateTime(id, name, properties));
return field.AddUserInfo(Geolocation(id, name, properties));
}
public static ArrayField AddGeolocation(this ArrayField field, long id, string name,
GeolocationFieldProperties? properties = null)
public static ArrayField AddJson(
this ArrayField field,
long id,
string name,
JsonFieldProperties? properties = null
)
{
return field.AddField(Geolocation(id, name, properties));
return field.AddUserInfo(Json(id, name, properties));
}
public static ArrayField AddJson(this ArrayField field, long id, string name,
JsonFieldProperties? properties = null)
public static ArrayField AddNumber(
this ArrayField field,
long id,
string name,
NumberFieldProperties? properties = null
)
{
return field.AddField(Json(id, name, properties));
return field.AddUserInfo(Number(id, name, properties));
}
public static ArrayField AddNumber(this ArrayField field, long id, string name,
NumberFieldProperties? properties = null)
public static ArrayField AddReferences(
this ArrayField field,
long id,
string name,
ReferencesFieldProperties? properties = null
)
{
return field.AddField(Number(id, name, properties));
return field.AddUserInfo(References(id, name, properties));
}
public static ArrayField AddReferences(this ArrayField field, long id, string name,
ReferencesFieldProperties? properties = null)
public static ArrayField AddRichText(
this ArrayField field,
long id,
string name,
RichTextFieldProperties? properties = null
)
{
return field.AddField(References(id, name, properties));
return field.AddUserInfo(RichText(id, name, properties));
}
public static ArrayField AddRichText(this ArrayField field, long id, string name,
RichTextFieldProperties? properties = null)
public static ArrayField AddString(
this ArrayField field,
long id,
string name,
StringFieldProperties? properties = null
)
{
return field.AddField(RichText(id, name, properties));
return field.AddUserInfo(String(id, name, properties));
}
public static ArrayField AddString(this ArrayField field, long id, string name,
StringFieldProperties? properties = null)
public static ArrayField AddTags(
this ArrayField field,
long id,
string name,
TagsFieldProperties? properties = null
)
{
return field.AddField(String(id, name, properties));
return field.AddUserInfo(Tags(id, name, properties));
}
public static ArrayField AddTags(this ArrayField field, long id, string name,
TagsFieldProperties? properties = null)
public static ArrayField AddUI(
this ArrayField field,
long id,
string name,
UIFieldProperties? properties = null
)
{
return field.AddField(Tags(id, name, properties));
return field.AddUserInfo(UI(id, name, properties));
}
public static ArrayField AddUI(this ArrayField field, long id, string name,
UIFieldProperties? properties = null)
public static ArrayField AddUserInfo(
this ArrayField field,
long id,
string name,
UserInfoFieldProperties? properties = null
)
{
return field.AddField(UI(id, name, properties));
return field.AddUserInfo(User(id, name, properties));
}
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs

@ -36,4 +36,6 @@ public interface IFieldPropertiesVisitor<out T, in TArgs>
T Visit(TagsFieldProperties properties, TArgs args);
T Visit(UIFieldProperties properties, TArgs args);
T Visit(UserInfoFieldProperties properties, TArgs args);
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs

@ -36,4 +36,6 @@ public interface IFieldVisitor<out T, in TArgs>
T Visit(IField<TagsFieldProperties> field, TArgs args);
T Visit(IField<UIFieldProperties> field, TArgs args);
T Visit(IField<UserInfoFieldProperties> field, TArgs args);
}

33
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/UserInfoFieldProperties.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Schemas;
public record class UserInfoFieldProperties : FieldProperties
{
public string? DefaultRole { get; init; }
public override T Accept<T, TArgs>(IFieldPropertiesVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this, args);
}
public override T Accept<T, TArgs>(IFieldVisitor<T, TArgs> visitor, IField field, TArgs args)
{
return visitor.Visit((IField<UserInfoFieldProperties>)field, args);
}
public override RootField CreateRootField(long id, string name, Partitioning partitioning)
{
return Fields.UserInfo(id, name, partitioning, this);
}
public override NestedField CreateNestedField(long id, string name)
{
return Fields.User(id, name, this);
}
}

1
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/AddDefaultValues.cs

@ -88,7 +88,6 @@ public sealed class AddDefaultValues(PartitionResolver partitionResolver, IClock
}
var defaultValue = DefaultValueFactory.CreateDefaultValue(field, GetNow(), key);
if (defaultValue == default)
{
return;

5
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs

@ -67,7 +67,6 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
// Some conversions are faster to do upfront, e.g. to remove hidden fields.
var newData = ConvertField(field, fieldData);
if (newData == null)
{
continue;
@ -98,7 +97,6 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
foreach (var converter in fieldConverters)
{
var newData = converter.ConvertFieldBefore(field, data);
if (newData == null)
{
return null;
@ -115,7 +113,6 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
foreach (var converter in fieldConverters)
{
var newData = converter.ConvertFieldAfter(field, data);
if (newData == null)
{
return null;
@ -157,7 +154,6 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
var oldValue = array[i];
var (removed, newValue) = ConvertArrayItem(field, oldValue);
if (removed)
{
array.RemoveAt(i);
@ -184,7 +180,6 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
var oldValue = array[i];
var (removed, newValue) = ConvertComponent(oldValue, parent);
if (removed)
{
array.RemoveAt(i);

5
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueChecker.cs

@ -94,4 +94,9 @@ internal sealed class DefaultValueChecker : IFieldPropertiesVisitor<bool, None>
{
return false;
}
public bool Visit(UserInfoFieldProperties properties, None args)
{
return !string.IsNullOrWhiteSpace(properties.DefaultRole);
}
}

11
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/DefaultValueFactory.cs

@ -7,6 +7,7 @@
using System.Globalization;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.Json.Objects;
@ -118,6 +119,16 @@ public sealed class DefaultValueFactory : IFieldPropertiesVisitor<JsonValue, Def
return JsonValue.Null;
}
public JsonValue Visit(UserInfoFieldProperties properties, Args args)
{
if (!string.IsNullOrWhiteSpace(properties.DefaultRole))
{
return UserInfoValue.CreateDefault(properties.DefaultRole);
}
return JsonValue.Null;
}
public JsonValue Visit(DateTimeFieldProperties properties, Args args)
{
if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now)

5
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs

@ -94,6 +94,11 @@ public sealed class StringFormatter : IFieldPropertiesVisitor<string, StringForm
return "<Json />";
}
public string Visit(UserInfoFieldProperties properties, Args args)
{
return "[User]";
}
public string Visit(NumberFieldProperties properties, Args args)
{
return args.Value.ToString();

5
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs

@ -100,6 +100,11 @@ internal sealed class ReferencesCleaner : IFieldPropertiesVisitor<JsonValue, Ref
return args.Value;
}
public JsonValue Visit(UserInfoFieldProperties properties, Args args)
{
return args.Value;
}
private static JsonValue CleanIds(Args args)
{
if (args.Value.Value is JsonArray array)

5
backend/src/Squidex.Domain.Apps.Core.Operations/GenerateFilters/FilterVisitor.cs

@ -124,6 +124,11 @@ internal sealed class FilterVisitor : IFieldVisitor<FilterSchema?, FilterVisitor
return null;
}
public FilterSchema? Visit(IField<UserInfoFieldProperties> field, Args args)
{
return FilterSchema.Any;
}
public FilterSchema? Visit(IField<ComponentFieldProperties> field, Args args)
{
if (args.Level >= MaxDepth)

15
backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs

@ -257,6 +257,21 @@ internal sealed class JsonTypeVisitor : IFieldVisitor<JsonSchemaProperty?, JsonT
return null;
}
public JsonSchemaProperty? Visit(IField<UserInfoFieldProperties> field, Args args)
{
var scheme = JsonTypeBuilder.Object();
scheme.Properties.Add(
"apiKey",
JsonTypeBuilder.StringProperty(FieldDescriptions.UserInfoApiKey, true));
scheme.Properties.Add(
"role",
JsonTypeBuilder.StringProperty(FieldDescriptions.UserInfoRole, true));
return JsonTypeBuilder.ObjectProperty(scheme);
}
private static void BuildComponent(JsonSchema jsonSchema, ReadonlyList<DomainId>? schemaIds, Args args)
{
if (args.WithComponents)

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

@ -111,7 +111,7 @@ public sealed class ContentScriptVars : DataScriptVars
}
[FieldDescription(nameof(FieldDescriptions.EntityCreated))]
public Instant Created
public Instant Created
{
set => SetInitial(value);
}

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

@ -59,7 +59,7 @@ public class ScriptVars : IDictionary<string, object?>
}
public void CopyTo(KeyValuePair<string, object?>[] array, int arrayIndex)
{
{
Guard.NotNull(array);
((IDictionary)values).CopyTo(array, arrayIndex);
}

12
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/DefaultFieldValueValidatorsFactory.cs

@ -263,6 +263,16 @@ internal sealed class DefaultFieldValueValidatorsFactory : IFieldVisitor<IEnumer
}
}
public IEnumerable<IValidator> Visit(IField<UserInfoFieldProperties> field, Args args)
{
var properties = field.Properties;
if (IsRequired(properties, args.Context, out _))
{
yield return new RequiredValidator();
}
}
private static bool IsRequired(FieldProperties properties, ValidationContext context, out bool result)
{
var isRequired = properties.IsRequired;
@ -277,7 +287,7 @@ internal sealed class DefaultFieldValueValidatorsFactory : IFieldVisitor<IEnumer
return isRequired;
}
private static IValidator ComponentValidator(ValidatorFactory factory)
private static ComponentValidator ComponentValidator(ValidatorFactory factory)
{
return new ComponentValidator(schema =>
{

17
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs

@ -155,6 +155,23 @@ public sealed class JsonValueConverter : IFieldPropertiesVisitor<(object? Result
}
}
public (object? Result, JsonError? Error) Visit(UserInfoFieldProperties properties, Args args)
{
var result = UserInfoValue.TryParse(args.Value, out var value);
switch (result)
{
case UserInfoParseResult.InvalidApiKey:
return (null, new JsonError(T.Get("contents.invalidUserInfoApiKey")));
case UserInfoParseResult.InvalidRole:
return (null, new JsonError(T.Get("contents.invalidUserInfoRole")));
case UserInfoParseResult.InvalidValue:
return (null, new JsonError(T.Get("contents.invalidUserInfo")));
default:
return (value, null);
}
}
private static (object? Result, JsonError? Error) ConvertToIdList(JsonValue value)
{
if (value.Value is JsonArray a)

9
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueValidator.cs

@ -75,9 +75,7 @@ public sealed class JsonValueValidator : IFieldPropertiesVisitor<bool, JsonValue
public bool Visit(GeolocationFieldProperties properties, Args args)
{
var result = GeoJsonValue.TryParse(args.Value, args.Serializer, out _);
return result == GeoJsonParseResult.Success;
return GeoJsonValue.TryParse(args.Value, args.Serializer, out _) == GeoJsonParseResult.Success;
}
public bool Visit(JsonFieldProperties properties, Args args)
@ -115,6 +113,11 @@ public sealed class JsonValueValidator : IFieldPropertiesVisitor<bool, JsonValue
return true;
}
public bool Visit(UserInfoFieldProperties properties, Args args)
{
return UserInfoValue.TryParse(args.Value, out _) == UserInfoParseResult.Success;
}
private static bool IsValidStringList(JsonValue value)
{
if (value.Value is not JsonArray a)

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs

@ -115,4 +115,9 @@ internal sealed class FieldInputVisitor(Builder builder) : IFieldVisitor<IGraphT
{
return null;
}
public IGraphType? Visit(IField<UserInfoFieldProperties> field, FieldInfo args)
{
return Scalars.Json;
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs

@ -266,6 +266,11 @@ internal sealed class FieldVisitor(Builder builder) : IFieldVisitor<FieldGraphSc
return default;
}
public FieldGraphSchema Visit(IField<UserInfoFieldProperties> field, FieldInfo args)
{
return new (Scalars.Json, JsonPath, ContentActions.Json.Arguments);
}
private IGraphType? ResolveReferences(FieldInfo fieldInfo, ReadonlyList<DomainId>? schemaIds)
{
IGraphType? contentType = null;

14
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ApiKeyQuery.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Text;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
public sealed record ApiKeyQuery(string ApiKey)
{
}

24
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs

@ -41,6 +41,30 @@ public static class Extensions
return result;
}
public static List<UserInfoValue>? ToUserInfos(this ContentData data)
{
List<UserInfoValue>? result = null;
foreach (var (field, value) in data)
{
if (value != null)
{
foreach (var (key, jsonValue) in value)
{
UserInfoValue.TryParse(jsonValue, out var userInfo);
if (userInfo != null)
{
result ??= [];
result.Add(userInfo);
}
}
}
}
return result;
}
public static Dictionary<string, string>? ToTexts(this ContentData data)
{
Dictionary<string, string>? result = null;

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs

@ -8,8 +8,13 @@
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public record class UserInfoResult(DomainId ContentId, string Role);
public interface ITextIndex
{
Task<List<DomainId>?> SearchAsync(App app, TextQuery query, SearchScope scope,
@ -18,6 +23,9 @@ public interface ITextIndex
Task<List<DomainId>?> SearchAsync(App app, GeoQuery query, SearchScope scope,
CancellationToken ct = default);
Task<UserInfoResult?> FindUserInfo(App app, ApiKeyQuery query, SearchScope scope,
CancellationToken ct = default);
Task ClearAsync(
CancellationToken ct = default);

1
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -106,6 +106,7 @@ public sealed class TextIndexingProcess(
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts(),
UserInfos = data.ToUserInfos(),
});
states[state.UniqueContentId] = state;

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs

@ -6,6 +6,7 @@
// ==========================================================================
using NetTopologySuite.Geometries;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
@ -15,6 +16,8 @@ public sealed class UpsertIndexEntry : IndexCommand
public Dictionary<string, string>? Texts { get; set; }
public List<UserInfoValue>? UserInfos { get; set; }
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }

5
backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/FieldPropertiesValidator.cs

@ -316,6 +316,11 @@ public sealed class FieldPropertiesValidator : IFieldPropertiesVisitor<IEnumerab
}
}
public IEnumerable<ValidationError> Visit(UserInfoFieldProperties properties, None args)
{
yield break;
}
private static bool IsMaxGreaterThanMin<T>(T? min, T? max) where T : struct, IComparable
{
return max.HasValue && min.HasValue && min.Value.CompareTo(max.Value) < 0;

2
backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs

@ -37,7 +37,7 @@ public partial class SchemaDomainObject
{
var field = e.Properties.CreateNestedField(e.FieldId.Id, e.Name);
newSnapshot = newSnapshot.UpdateField(e.ParentFieldId.Id, x => ((ArrayField)x).AddField(field));
newSnapshot = newSnapshot.UpdateField(e.ParentFieldId.Id, x => ((ArrayField)x).AddUserInfo(field));
}
else
{

9
backend/src/Squidex.Shared/Texts.de.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Ungültiger JSON-Typ, Zeichenfolge erwartet.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Referenz(en)</value>
</data>

9
backend/src/Squidex.Shared/Texts.fr.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Type json non valide, chaîne attendue.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Les références)</value>
</data>

9
backend/src/Squidex.Shared/Texts.it.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Errore nel json, atteso una string.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Collegamenti(s)</value>
</data>

9
backend/src/Squidex.Shared/Texts.nl.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Ongeldig json-type, verwachte string.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} referentie (s)</value>
</data>

9
backend/src/Squidex.Shared/Texts.pt.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Json type inválido, esperado texto.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Referencia(s)</value>
</data>

9
backend/src/Squidex.Shared/Texts.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>Invalid json type, expected string.</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} Reference(s)</value>
</data>

9
backend/src/Squidex.Shared/Texts.zh.resx

@ -502,6 +502,15 @@
<data name="contents.invalidString" xml:space="preserve">
<value>无效的 json 类型,需要的字符串。</value>
</data>
<data name="contents.invalidUserInfo" xml:space="preserve">
<value>Invalid json type, expected apiKey+role object.</value>
</data>
<data name="contents.invalidUserInfoApiKey" xml:space="preserve">
<value>ApiKey must be defined.</value>
</data>
<data name="contents.invalidUserInfoRole" xml:space="preserve">
<value>Role must be defined.</value>
</data>
<data name="contents.listReferences" xml:space="preserve">
<value>{count} 个引用</value>
</data>

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

@ -32,6 +32,10 @@ public static class Constants
public const string ScopeApi = "squidex-api";
public const string ClaimTypeApp = "app/name";
public const string ClaimTypeRole = "app/role";
public static readonly string ClientFrontendId = DefaultClients.Frontend;
public static readonly string ClientInternalId = "squidex-internal";

8
backend/src/Squidex.Web/Extensions.cs

@ -12,6 +12,14 @@ namespace Squidex.Web;
public static class Extensions
{
public static (string? UserId, string? App, string? Role) GetUserInfo(this ClaimsPrincipal principal)
{
return (
principal.FindFirst(ClaimTypes.NameIdentifier)?.Value,
principal.FindFirst(Constants.ClaimTypeApp)?.Value,
principal.FindFirst(Constants.ClaimTypeRole)?.Value);
}
public static string? GetClientId(this ClaimsPrincipal principal)
{
var clientId = principal.FindFirst(OpenIdClaims.ClientId)?.Value;

20
backend/src/Squidex.Web/Pipeline/ApiKeyAuthenticationBuilderExtensions.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Web.Pipeline;
#pragma warning disable IDE0130 // Namespace does not match folder structure
namespace Microsoft.AspNetCore.Authentication;
public static class ApiKeyAuthenticationBuilderExtensions
{
public static AuthenticationBuilder AddApiKey(this AuthenticationBuilder builder)
{
return builder.AddScheme<ApiKeyOptions, ApiKeyHandler>(ApiKeyDefaults.AuthenticationScheme, _ => { });
}
}

13
backend/src/Squidex.Web/Pipeline/ApiKeyDefaults.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Web.Pipeline;
public static class ApiKeyDefaults
{
public const string AuthenticationScheme = "ApiKey";
}

131
backend/src/Squidex.Web/Pipeline/ApiKeyHandler.cs

@ -0,0 +1,131 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
namespace Squidex.Web.Pipeline;
public sealed class ApiKeyHandler(
IAppProvider appProvider,
ITextIndex textIndex,
IOptionsMonitor<ApiKeyOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<ApiKeyOptions>(options, logger, encoder)
{
private const string ApiKeyPrefix = "ApiKey ";
private const string ApiKeyHeader = "ApiKey";
private const string ApiKeyHeaderX = "X-ApiKey";
private const string ApiKeyQuery = "api_key";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
if (!IsApiKey(Request, out var apiKey))
{
return AuthenticateResult.NoResult();
}
var keyParts = apiKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (keyParts.Length != 2)
{
return AuthenticateResult.Fail("Invalid API Key");
}
var app = await appProvider.GetAppAsync(keyParts[0], true, Context.RequestAborted);
if (app == null)
{
return AuthenticateResult.Fail("Invalid API Key");
}
var user =
await textIndex.FindUserInfo(
app,
new ApiKeyQuery(keyParts[1]),
SearchScope.Published,
Context.RequestAborted);
if (user == null)
{
return AuthenticateResult.Fail("Invalid API Key");
}
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.ContentId.ToString()),
new Claim(Constants.ClaimTypeApp, app.Name),
new Claim(Constants.ClaimTypeRole, user.Role),
], ApiKeyDefaults.AuthenticationScheme);
return Success(identity);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error while handling api key.");
throw;
}
}
private AuthenticateResult Success(ClaimsIdentity identity)
{
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
public static bool IsApiKey(HttpRequest request, [MaybeNullWhen(false)] out string apiKey)
{
apiKey = null!;
string? authorizationHeader = request.Headers[HeaderNames.Authorization];
if (authorizationHeader?.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase) == true)
{
var key = authorizationHeader[ApiKeyPrefix.Length..].Trim();
if (!string.IsNullOrWhiteSpace(key))
{
apiKey = key;
return true;
}
}
string? apiKeyHeader = request.Headers[ApiKeyHeader];
if (!string.IsNullOrWhiteSpace(apiKeyHeader))
{
apiKey = apiKeyHeader;
return true;
}
string? apiKeyHeaderX = request.Headers[ApiKeyHeaderX];
if (!string.IsNullOrWhiteSpace(apiKeyHeaderX))
{
apiKey = apiKeyHeaderX;
return true;
}
string? apiKeyQuery = request.Query[ApiKeyQuery];
if (!string.IsNullOrWhiteSpace(apiKeyQuery))
{
apiKey = apiKeyQuery;
return true;
}
return false;
}
}

14
backend/src/Squidex.Web/Pipeline/ApiKeyOptions.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Authentication;
namespace Squidex.Web.Pipeline;
public sealed class ApiKeyOptions : AuthenticationSchemeOptions
{
}

26
backend/src/Squidex.Web/Pipeline/AppResolver.cs

@ -28,7 +28,6 @@ public sealed class AppResolver(IAppProvider appProvider) : IAsyncActionFilter
if (context.RouteData.Values.TryGetValue("app", out var appValue))
{
var appName = appValue?.ToString();
if (string.IsNullOrWhiteSpace(appName))
{
context.Result = new NotFoundResult();
@ -51,12 +50,16 @@ public sealed class AppResolver(IAppProvider appProvider) : IAsyncActionFilter
string? clientId = null;
var (role, permissions) = FindByOpenIdSubject(app, user, isFrontend);
if (permissions == null)
{
(clientId, role, permissions) = FindByOpenIdClient(app, user, isFrontend);
}
if (permissions == null)
{
(role, permissions) = FindByUserInfo(app, user, isFrontend);
}
if (permissions == null)
{
(clientId, role, permissions) = FindAnonymousClient(app, isFrontend);
@ -125,7 +128,6 @@ public sealed class AppResolver(IAppProvider appProvider) : IAsyncActionFilter
private static (string?, string?, PermissionSet?) FindByOpenIdClient(App app, ClaimsPrincipal user, bool isFrontend)
{
var (appName, clientId) = user.GetClient();
if (app.Name != appName || clientId == null)
{
return default;
@ -139,10 +141,25 @@ public sealed class AppResolver(IAppProvider appProvider) : IAsyncActionFilter
return default;
}
private static (string?, PermissionSet?) FindByUserInfo(App app, ClaimsPrincipal user, bool isFrontend)
{
var (_, appName, roleName) = user.GetUserInfo();
if (app.Name != appName || roleName == null)
{
return default;
}
if (app.TryGetRole(roleName, isFrontend, out var role))
{
return (role.Name, role.Permissions);
}
return default;
}
private static (string?, string?, PermissionSet?) FindAnonymousClient(App app, bool isFrontend)
{
var client = app.Clients.FirstOrDefault(x => x.Value.AllowAnonymous);
if (client.Value == null)
{
return default;
@ -159,7 +176,6 @@ public sealed class AppResolver(IAppProvider appProvider) : IAsyncActionFilter
private static (string?, PermissionSet?) FindByOpenIdSubject(App app, ClaimsPrincipal user, bool isFrontend)
{
var subjectId = user.OpenIdSubject();
if (subjectId == null)
{
return default;

5
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs

@ -93,4 +93,9 @@ internal sealed class FieldPropertiesDtoFactory : IFieldPropertiesVisitor<FieldP
{
return UIFieldPropertiesDto.FromDomain(fieldProperties);
}
public FieldPropertiesDto Visit(UserInfoFieldProperties fieldProperties, None args)
{
return UserInfoFieldPropertiesDto.FromDomain(fieldProperties);
}
}

31
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/UserInfoFieldPropertiesDto.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure.Reflection;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields;
[OpenApiRequest]
public sealed class UserInfoFieldPropertiesDto : FieldPropertiesDto
{
/// <summary>
/// The role to create a default value.
/// </summary>
public string? DefaultRole { get; init; }
public static UserInfoFieldPropertiesDto FromDomain(UserInfoFieldProperties fieldProperties)
{
return SimpleMapper.Map(fieldProperties, new UserInfoFieldPropertiesDto());
}
public override FieldProperties ToProperties()
{
return SimpleMapper.Map(this, new UserInfoFieldProperties());
}
}

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

@ -6,7 +6,9 @@
// ==========================================================================
using Microsoft.AspNetCore.Authentication;
using OpenIddict.Validation.AspNetCore;
using Squidex.Hosting.Web;
using Squidex.Web.Pipeline;
namespace Squidex.Config.Authentication;
@ -18,6 +20,7 @@ public static class AuthenticationServices
services.AddAuthentication()
.AddSquidexCookies(config)
.AddApiKey()
.AddSquidexExternalGithubAuthentication(identityOptions)
.AddSquidexExternalGoogleAuthentication(identityOptions)
.AddSquidexExternalMicrosoftAuthentication(identityOptions)

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

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using OpenIddict.Validation.AspNetCore;
using Squidex.Hosting;
using Squidex.Web;
using Squidex.Web.Pipeline;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Squidex.Config.Authentication;
@ -19,8 +20,9 @@ public static class IdentityServerServices
{
public static AuthenticationBuilder AddSquidexIdentityServerAuthentication(this AuthenticationBuilder authBuilder, MyIdentityOptions identityOptions, IConfiguration config)
{
var useCustomAuthorityUrl = !string.IsNullOrWhiteSpace(identityOptions.AuthorityUrl);
var defaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
var useCustomAuthorityUrl = !string.IsNullOrWhiteSpace(identityOptions.AuthorityUrl);
if (useCustomAuthorityUrl)
{
const string ExternalIdentityServerSchema = nameof(ExternalIdentityServerSchema);
@ -34,21 +36,23 @@ public static class IdentityServerServices
options.Scope.Add(Constants.ScopeApi);
});
authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, Constants.ApiSecurityScheme, options =>
{
options.ForwardDefaultSelector = context => ExternalIdentityServerSchema;
});
defaultScheme = ExternalIdentityServerSchema;
}
else
{
authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, Constants.ApiSecurityScheme, options =>
authBuilder.AddPolicyScheme(Constants.ApiSecurityScheme, null, options =>
{
options.ForwardDefaultSelector = _ => OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.ForwardDefaultSelector = context =>
{
if (ApiKeyHandler.IsApiKey(context.Request, out _))
{
return ApiKeyDefaults.AuthenticationScheme;
}
return defaultScheme;
};
});
}
authBuilder.AddOpenIdConnect();
authBuilder.Services.AddOptions<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme)
.Configure<IUrlGenerator>((options, urlGenerator) =>
{

2
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexTests.cs

@ -17,6 +17,8 @@ public abstract class EFTextIndexTests<TContext>(ISqlFixture<TContext> fixture)
{
public override bool SupportsQuerySyntax => false;
public override bool SupportsGeo => true;
public override async Task<ITextIndex> CreateSutAsync()
{
var sut = new EFTextIndex<TContext>(fixture.DbContextFactory);

34
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Schemas/ArrayFieldTests.cs

@ -20,7 +20,7 @@ public class ArrayfieldTests
{
var field = CreateField(1);
var parent_1 = parent_0.AddField(field);
var parent_1 = parent_0.AddUserInfo(field);
Assert.Empty(parent_0.Fields);
Assert.Equal(field, parent_1.FieldsById[1]);
@ -29,7 +29,7 @@ public class ArrayfieldTests
[Fact]
public void Should_throw_exception_if_adding_field_with_name_that_already_exists()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
Assert.Throws<ArgumentException>(() => parent_1.AddNumber(2, "myField1"));
}
@ -37,7 +37,7 @@ public class ArrayfieldTests
[Fact]
public void Should_throw_exception_if_adding_field_with_id_that_already_exists()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
Assert.Throws<ArgumentException>(() => parent_1.AddNumber(1, "myField2"));
}
@ -45,7 +45,7 @@ public class ArrayfieldTests
[Fact]
public void Should_hide_field()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.UpdateField(1, f => f.Hide());
var parent_3 = parent_2.UpdateField(1, f => f.Hide());
@ -67,7 +67,7 @@ public class ArrayfieldTests
[Fact]
public void Should_show_field()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.UpdateField(1, f => f.Hide());
var parent_3 = parent_2.UpdateField(1, f => f.Show());
@ -90,7 +90,7 @@ public class ArrayfieldTests
[Fact]
public void Should_disable_field()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.UpdateField(1, f => f.Disable());
var parent_3 = parent_2.UpdateField(1, f => f.Disable());
@ -112,7 +112,7 @@ public class ArrayfieldTests
[Fact]
public void Should_enable_field()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.UpdateField(1, f => f.Disable());
var parent_3 = parent_2.UpdateField(1, f => f.Enable());
@ -144,7 +144,7 @@ public class ArrayfieldTests
MinValue = 10,
};
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.UpdateField(1, f => f.Update(properties1));
var parent_3 = parent_2.UpdateField(1, f => f.Update(properties2));
@ -157,7 +157,7 @@ public class ArrayfieldTests
[Fact]
public void Should_throw_exception_if_updating_with_invalid_properties_type()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
Assert.Throws<ArgumentException>(() => parent_1.UpdateField(1, f => f.Update(new StringFieldProperties())));
}
@ -173,7 +173,7 @@ public class ArrayfieldTests
[Fact]
public void Should_delete_field()
{
var parent_1 = parent_0.AddField(CreateField(1));
var parent_1 = parent_0.AddUserInfo(CreateField(1));
var parent_2 = parent_1.DeleteField(1);
var parent_3 = parent_2.DeleteField(1);
@ -197,9 +197,9 @@ public class ArrayfieldTests
var field2 = CreateField(2);
var field3 = CreateField(3);
var parent_1 = parent_0.AddField(field1);
var parent_2 = parent_1.AddField(field2);
var parent_3 = parent_2.AddField(field3);
var parent_1 = parent_0.AddUserInfo(field1);
var parent_2 = parent_1.AddUserInfo(field2);
var parent_3 = parent_2.AddUserInfo(field3);
var parent_4 = parent_3.ReorderFields([3, 2, 1]);
var parent_5 = parent_4.ReorderFields([3, 2, 1]);
@ -214,8 +214,8 @@ public class ArrayfieldTests
var field1 = CreateField(1);
var field2 = CreateField(2);
var parent_1 = parent_0.AddField(field1);
var parent_2 = parent_1.AddField(field2);
var parent_1 = parent_0.AddUserInfo(field1);
var parent_2 = parent_1.AddUserInfo(field2);
Assert.Throws<ArgumentException>(() => parent_2.ReorderFields([1]));
}
@ -226,8 +226,8 @@ public class ArrayfieldTests
var field1 = CreateField(1);
var field2 = CreateField(2);
var parent_1 = parent_0.AddField(field1);
var parent_2 = parent_1.AddField(field2);
var parent_1 = parent_0.AddUserInfo(field1);
var parent_2 = parent_1.AddUserInfo(field2);
Assert.Throws<ArgumentException>(() => parent_2.ReorderFields([1, 4]));
}

20
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/DefaultValueFactoryTests.cs

@ -316,6 +316,26 @@ public class DefaultValueFactoryTests
Assert.Equal(new JsonArray(), DefaultValueFactory.CreateDefaultValue(field, now, language.Iso2Code));
}
[Fact]
public void Should_get_default_value_from_userinfo_field_if_role_is_not_set()
{
var field =
Fields.UserInfo(1, "1", Partitioning.Invariant,
new UserInfoFieldProperties());
Assert.Equal(JsonValue.Null, DefaultValueFactory.CreateDefaultValue(field, now, language.Iso2Code));
}
[Fact]
public void Should_get_default_value_from_userinfo_field_if_role_is_set()
{
var field =
Fields.UserInfo(1, "1", Partitioning.Invariant,
new UserInfoFieldProperties { DefaultRole = "myRole" });
Assert.IsType<JsonObject>(DefaultValueFactory.CreateDefaultValue(field, now, language.Iso2Code).Value);
}
private Instant FutureDays(int days)
{
return now.WithoutMs().Plus(Duration.FromDays(days));

11
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/GeolocationFieldTests.cs

@ -61,6 +61,17 @@ public class GeolocationFieldTests : IClassFixture<TranslationsFixture>
Assert.Empty(errors);
}
[Fact]
public async Task Should_add_error_if_geolocation_is_not_an_object()
{
var sut = Field(new GeolocationFieldProperties { IsRequired = true });
await sut.ValidateAsync(JsonValue.True, errors);
errors.Should().BeEquivalentTo(
["Invalid json type, expected latitude/longitude object."]);
}
[Fact]
public async Task Should_add_error_if_geolocation_has_invalid_latitude()
{

99
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/UserInfoFieldPropertiesTests.cs

@ -0,0 +1,99 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Operations.ValidateContent;
public class UserInfoFieldPropertiesTests : IClassFixture<TranslationsFixture>
{
private readonly List<string> errors = [];
[Fact]
public void Should_instantiate_field()
{
var sut = Field(new UserInfoFieldProperties());
Assert.Equal("myUserInfo", sut.Name);
}
[Fact]
public async Task Should_not_add_error_if_userinfo_is_valid_null()
{
var sut = Field(new UserInfoFieldProperties());
await sut.ValidateAsync(JsonValue.Null, errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_userinfo_is_valid()
{
var sut = Field(new UserInfoFieldProperties());
await sut.ValidateAsync(CreateValue("Key", "Role"), errors);
Assert.Empty(errors);
}
[Fact]
public async Task Should_add_error_if_userinfo_is_not_an_object()
{
var sut = Field(new UserInfoFieldProperties { IsRequired = true });
await sut.ValidateAsync(JsonValue.True, errors);
errors.Should().BeEquivalentTo(
["Invalid json type, expected apiKey+role object."]);
}
[Fact]
public async Task Should_add_error_if_userinfo_has_invalid_role()
{
var sut = Field(new UserInfoFieldProperties { IsRequired = true });
await sut.ValidateAsync(CreateValue("Key", null), errors);
errors.Should().BeEquivalentTo(
["Role must be defined."]);
}
[Fact]
public async Task Should_add_error_if_userinfo_has_invalid_apiKey()
{
var sut = Field(new UserInfoFieldProperties { IsRequired = true });
await sut.ValidateAsync(CreateValue(null, "Role"), errors);
errors.Should().BeEquivalentTo(
["ApiKey must be defined."]);
}
[Fact]
public async Task Should_add_error_if_userinfo_is_required()
{
var sut = Field(new UserInfoFieldProperties { IsRequired = true });
await sut.ValidateAsync(JsonValue.Null, errors);
errors.Should().BeEquivalentTo(
["Field is required."]);
}
private static JsonValue CreateValue(string? apiKey, string? role)
{
return JsonValue.Object().Add("apiKey", apiKey).Add("role", role);
}
private static RootField<UserInfoFieldProperties> Field(UserInfoFieldProperties properties)
{
return Fields.UserInfo(1, "myUserInfo", Partitioning.Invariant, properties);
}
}

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

@ -136,6 +136,9 @@ public static class TestContent
text
}
}
myUserInfo {
iv
}
}";
public const string AllFlatFields = @"
@ -225,6 +228,7 @@ public static class TestContent
markdown
text
}
myUserInfo
}";
public static EnrichedContent Create(DomainId id, ContentData? data = null)
@ -340,7 +344,12 @@ public static class TestContent
.Add("content", JsonValue.Array(
JsonValue.Object()
.Add("type", "text")
.Add("text", "Rich Text")))));
.Add("text", "Rich Text")))))
.AddField("my-user-info",
new ContentFieldData()
.AddInvariant(JsonValue.Object()
.Add("apiKey", "MyKey")
.Add("role", "MyRole")));
var content = new EnrichedContent
{
@ -621,6 +630,10 @@ public static class TestContent
},
},
},
["myUserInfo"] = new
{
iv = new { apiKey = "MyKey", role = "MyRole" },
},
};
return actual;
@ -814,6 +827,10 @@ public static class TestContent
text = "Rich Text",
},
},
["myUserInfo"] = new
{
iv = new { apiKey = "MyKey", role = "MyRole" },
},
};
return actual;
@ -956,6 +973,7 @@ public static class TestContent
markdown = "# Rich Text",
text = "Rich Text",
},
["myUserInfo"] = new { apiKey = "MyKey", role = "MyRole" },
};
return actual;

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestSchemas.cs

@ -136,6 +136,8 @@ public static class TestSchemas
new StringFieldProperties { IsEmbeddable = true, SchemaIds = ReadonlyList.Create(Reference1.Id, Reference2.Id) })
.AddRichText(18, "my-richtext", Partitioning.Invariant,
new RichTextFieldProperties { SchemaIds = ReadonlyList.Create(Reference1.Id, Reference2.Id) })
.AddUserInfo(19, "my-user-info", Partitioning.Invariant,
new UserInfoFieldProperties())
.AddArray(100, "my-array", Partitioning.Invariant, f => f
.AddBoolean(121, "nested-boolean",
new BooleanFieldProperties())

61
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests.cs

@ -114,6 +114,24 @@ public abstract class TextIndexerTests : GivenContext
await SearchGeo(expected: null, "other.iv", 51.48596429889613, 12.102629469505713);
}
[Fact]
public async Task Should_search_by_userinfo()
{
var field = Guid.NewGuid().ToString();
// With same ApiKey
await CreateUserInfoAsync(Ids1[0], field, "Key1", "Role1");
// Other ApiKey
await CreateUserInfoAsync(Ids2[0], field, "Key2", "Role2");
// With same ApiKey
await SearchApiKey(Ids1[0], "Key1");
// Wrong ApiKey
await SearchApiKey(null, "Key3");
}
[Fact]
public async Task Should_search_by_app()
{
@ -356,6 +374,13 @@ public abstract class TextIndexerTests : GivenContext
return UpdateAsync(id, new ContentCreated { Data = data });
}
protected Task CreateUserInfoAsync(DomainId id, string field, string apiKey, string role)
{
var data = UserInfoData(field, apiKey, role);
return UpdateAsync(id, new ContentCreated { Data = data });
}
protected Task UpdateTextAsync(DomainId id, string language, string text)
{
var data = TextData(language, text);
@ -422,6 +447,14 @@ public abstract class TextIndexerTests : GivenContext
.AddInvariant(JsonValue.Object().Add("latitude", latitude).Add("longitude", longitude)));
}
private static ContentData UserInfoData(string field, string apiKey, string role)
{
return new ContentData()
.AddField(field,
new ContentFieldData()
.AddInvariant(JsonValue.Object().Add("apiKey", apiKey).Add("role", role)));
}
private static ContentData GeoJsonData(string field, double latitude, double longitude)
{
return new ContentData()
@ -435,18 +468,34 @@ public abstract class TextIndexerTests : GivenContext
.Add(latitude))));
}
protected async Task SearchGeo(List<DomainId>? expected, string field, double latitude, double longitude, SearchScope target = SearchScope.All)
protected async Task SearchApiKey(
DomainId? expected,
string apiKey,
SearchScope target = SearchScope.All)
{
var query = new GeoQuery(SchemaId.Id, field, latitude, longitude, 1000, 1000)
{
SchemaId = SchemaId.Id,
};
var query = new ApiKeyQuery(apiKey);
var actual = await SearchAsync(i => i.FindUserInfo(App, query, target, default), x => x?.ContentId == expected);
Assert.Equal(expected, actual?.ContentId);
}
protected async Task SearchGeo(
List<DomainId>? expected,
string field,
double latitude,
double longitude,
SearchScope target = SearchScope.All)
{
var query = new GeoQuery(SchemaId.Id, field, latitude, longitude, 1000, 1000);
var actual = await SearchAsync(i => i.SearchAsync(App, query, target, default), x => IsExpected(x, expected));
AssertIds(actual, expected);
}
protected async Task SearchText(List<DomainId>? expected, string text, SearchScope target = SearchScope.All)
protected async Task SearchText(
List<DomainId>? expected,
string text,
SearchScope target = SearchScope.All)
{
var query = new TextQuery(text, 1000)
{

23838
frontend/cache.json

File diff suppressed because it is too large

4
frontend/src/app/features/content/pages/content/editor/content-editor.component.html

@ -12,11 +12,11 @@
</div>
@if (isDeleted) {
<div inline="true" [sqxMarkdown]="'contents.versionViewingDeleted' | sqxTranslate: { version: contentVersion }"></div>
<div [sqxMarkdown]="'contents.versionViewingDeleted' | sqxTranslate: { version: contentVersion }"></div>
}
@if (!isDeleted) {
<div inline="true" [sqxMarkdown]="'contents.versionViewing' | sqxTranslate: { version: contentVersion }"></div>
<div [sqxMarkdown]="'contents.versionViewing' | sqxTranslate: { version: contentVersion }"></div>
}
</div>
}

6
frontend/src/app/features/content/shared/forms/assets-editor.component.html

@ -1,7 +1,7 @@
<div class="row gx-1" [class.disabled]="snapshot.isDisabled">
<div class="row gx-1 header" [class.disabled]="snapshot.isDisabled">
<div class="col">
<div
class="btn btn-outline-secondary btn-dashed btn-block btn-add"
class="btn btn-outline-secondary btn-dashed btn-block btn-add mb-1"
(click)="fileInput.click()"
[sqxDropDisabled]="snapshot.isDisabled"
(sqxDropFile)="addFiles($event)">
@ -11,7 +11,7 @@
</div>
<div class="col">
<button class="btn btn-outline-secondary btn-dashed btn-block btn-add" (click)="assetsDialog.show()" type="button">
<button class="btn btn-outline-secondary btn-dashed btn-block btn-add mb-1" (click)="assetsDialog.show()" type="button">
{{ "contents.assetsSelect" | sqxTranslate }}
</button>
</div>

4
frontend/src/app/features/content/shared/forms/assets-editor.component.scss

@ -31,6 +31,10 @@
pointer-events: none;
}
.header {
margin-bottom: -.25rem;
}
.list-view {
margin-bottom: 1rem;
}

4
frontend/src/app/features/content/shared/forms/component-section.component.html

@ -4,7 +4,7 @@
<h3>{{ separator!.displayName }}</h3>
@if (separator.properties.hints && separator.properties.hints.length > 0) {
<sqx-form-hint> <span inline="true" optional="true" [sqxMarkdown]="separator.properties.hints"></span> </sqx-form-hint>
<sqx-form-hint> <span optional="true" [sqxMarkdown]="separator.properties.hints"></span> </sqx-form-hint>
}
</div>
}
@ -24,8 +24,8 @@
[formModel]="child"
[hasChatBot]="hasChatBot"
[index]="index"
[isComparing]="isComparing"
[isCollapsed]="false"
[isComparing]="isComparing"
[language]="language"
[languages]="languages" />
}

2
frontend/src/app/features/content/shared/forms/content-section.component.html

@ -13,7 +13,7 @@
@if (separator.properties.hints && separator.properties.hints.length > 0) {
<sqx-form-hint>
<span inline="true" optional="true" [sqxMarkdown]="separator.properties.hints"></span>
<span optional="true" [sqxMarkdown]="separator.properties.hints"></span>
</sqx-form-hint>
}
</div>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save