Compare commits

...
Author SHA1 Message Date
Andy ButlandandGitHub 06a2a500b3 Merge commit from fork
* Prevent path traveral vulnerability with upload of temporary files.

* Used BadRequest instead of NotFound for invalid file name response.
2025-04-08 05:03:40 +02:00
Nikolaj Geisle 4b016317f9 Skip lock tests 2025-04-04 17:34:08 +02:00
Andy ButlandandJacob Overgaard 1720692d3d Ensures date comparisons in schedule integration tests are made only on the datetime part to the second (#18894)
* Ensures date comparisons in schedule integration tests are made only on the date part.

* Include time part to the second.

* Ensure Kind is retained when truncating a date.

* Retain Kind for all truncation levels.
2025-04-01 09:45:35 +02:00
Jacob Overgaard fc815db80b bump version to 15.3.1 2025-03-31 14:48:54 +02:00
98e0615338 V15: Revert "Fix: RTE markup props not up to date issue" (#18879)
* Revert "simplifying the use of props (#18430)"

This reverts commit 347e898190.

* do not set value if identical check

cherry-picked from c03a8afab5

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2025-03-31 13:13:00 +02:00
26907f202f hotfix: context provider should not destroy instance (#18864)
* do not destroy instance

* Update src/Umbraco.Web.UI.Client/src/libs/context-api/provide/context-provider.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-31 10:51:45 +02:00
Lee KelleherandGitHub 85176d1bf6 Fixes localization culture case-sensitive check (#18849)
Fixes #18801.
2025-03-27 15:24:45 +00:00
131c9cda6f hotfix #18735 (#18750)
* fix multiple text string validation

* notify about messages

* cherry picked fix

* protection again unnecessary calls

* json path cherry pick + tests

* validation message change lock

* cherry pick from control lifecycle

* optimization

* propagate errors

* cherry picked sync

* query umb-input-multiple-text-string

* remove unused import

* remove optional chain expression

* use !

* outcomment the error handling

* outcomment more promise rejection error

* Fixed issue with multi URL picker.

* remove unesecary warning

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-03-20 16:22:05 +00:00
Sven Geusens f7854b8c95 Version bump 2025-03-20 11:08:38 +01:00
28 changed files with 364 additions and 201 deletions
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Routing;
@@ -17,6 +17,9 @@ public abstract class TemporaryFileControllerBase : ManagementApiControllerBase
.WithTitle("File extension not allowed")
.WithDetail("The file extension is not allowed.")
.Build()),
TemporaryFileOperationStatus.InvalidFileName => BadRequest(problemDetailsBuilder
.WithTitle("The provided file name is not valid")
.Build()),
TemporaryFileOperationStatus.KeyAlreadyUsed => BadRequest(problemDetailsBuilder
.WithTitle("Key already used")
.WithDetail("The specified key is already used.")
@@ -7,6 +7,9 @@ namespace Umbraco.Extensions;
public static class DateTimeExtensions
{
/// <summary>
/// Defines the levels to truncate a date to.
/// </summary>
public enum DateTruncate
{
Year,
@@ -25,33 +28,39 @@ public static class DateTimeExtensions
public static string ToIsoString(this DateTime dt) =>
dt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
/// <summary>
/// Truncates the date to the specified level, i.e. if you pass in DateTruncate.Hour it will truncate the date to the hour.
/// </summary>
/// <param name="dt">The date.</param>
/// <param name="truncateTo">The level to truncate the date to.</param>
/// <returns>The truncated date.</returns>
public static DateTime TruncateTo(this DateTime dt, DateTruncate truncateTo)
{
if (truncateTo == DateTruncate.Year)
{
return new DateTime(dt.Year, 1, 1);
return new DateTime(dt.Year, 1, 1, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Month)
{
return new DateTime(dt.Year, dt.Month, 1);
return new DateTime(dt.Year, dt.Month, 1, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Day)
{
return new DateTime(dt.Year, dt.Month, dt.Day);
return new DateTime(dt.Year, dt.Month, dt.Day, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Hour)
{
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0);
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Minute)
{
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0);
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, dt.Kind);
}
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second);
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind);
}
}
@@ -6,5 +6,6 @@ public enum TemporaryFileOperationStatus
FileExtensionNotAllowed = 1,
KeyAlreadyUsed = 2,
NotFound = 3,
UploadBlocked
UploadBlocked = 4,
InvalidFileName = 5,
}
@@ -45,7 +45,6 @@ internal sealed class TemporaryFileService : ITemporaryFileService
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.KeyAlreadyUsed, null);
}
await using Stream dataStream = createModel.OpenReadStream();
dataStream.Seek(0, SeekOrigin.Begin);
if (_fileStreamSecurityValidator.IsConsideredSafe(dataStream) is false)
@@ -53,13 +52,12 @@ internal sealed class TemporaryFileService : ITemporaryFileService
return Attempt.FailWithStatus<TemporaryFileModel?, TemporaryFileOperationStatus>(TemporaryFileOperationStatus.UploadBlocked, null);
}
temporaryFileModel = new TemporaryFileModel
{
Key = createModel.Key,
FileName = createModel.FileName,
OpenReadStream = createModel.OpenReadStream,
AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime)
AvailableUntil = DateTime.Now.Add(_runtimeSettings.TemporaryFileLifeTime),
};
await _temporaryFileRepository.SaveAsync(temporaryFileModel);
@@ -68,17 +66,29 @@ internal sealed class TemporaryFileService : ITemporaryFileService
}
private TemporaryFileOperationStatus Validate(TemporaryFileModelBase temporaryFileModel)
=> IsAllowedFileExtension(temporaryFileModel) == false
? TemporaryFileOperationStatus.FileExtensionNotAllowed
: TemporaryFileOperationStatus.Success;
private bool IsAllowedFileExtension(TemporaryFileModelBase temporaryFileModel)
{
var extension = Path.GetExtension(temporaryFileModel.FileName)[1..];
if (IsAllowedFileExtension(temporaryFileModel.FileName) == false)
{
return TemporaryFileOperationStatus.FileExtensionNotAllowed;
}
if (IsValidFileName(temporaryFileModel.FileName) == false)
{
return TemporaryFileOperationStatus.InvalidFileName;
}
return TemporaryFileOperationStatus.Success;
}
private bool IsAllowedFileExtension(string fileName)
{
var extension = Path.GetExtension(fileName)[1..];
return _contentSettings.IsFileAllowedForUpload(extension);
}
private static bool IsValidFileName(string fileName) =>
!string.IsNullOrEmpty(fileName) && fileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
public async Task<Attempt<TemporaryFileModel?, TemporaryFileOperationStatus>> DeleteAsync(Guid key)
{
TemporaryFileModel? model = await _temporaryFileRepository.GetAsync(key);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@umbraco-cms/backoffice",
"version": "15.3.0-rc",
"version": "15.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco-cms/backoffice",
"version": "15.3.0-rc",
"version": "15.3.1",
"license": "MIT",
"workspaces": [
"./src/packages/*"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "15.3.0-rc",
"version": "15.3.1",
"type": "module",
"exports": {
".": null,
@@ -374,8 +374,6 @@ export default {
fileSecurityValidationFailure: 'One or more file security validations have failed',
moveToSameFolderFailed: 'Parent and destination folders cannot be the same',
uploadNotAllowed: 'Upload is not allowed in this location.',
noticeExtensionsServerOverride:
'Regardless of the allowed file types, the following limitations apply system-wide due to the server configuration:',
},
member: {
'2fa': 'Two-Factor Authentication',
@@ -899,7 +897,6 @@ export default {
retrieve: 'Retrieve',
retry: 'Retry',
rights: 'Permissions',
serverConfiguration: 'Server Configuration',
scheduledPublishing: 'Scheduled Publishing',
umbracoInfo: 'Umbraco info',
search: 'Search',
@@ -48,8 +48,9 @@ export class UmbContextProviderController<
public override destroy(): void {
if (this.#host) {
this.#host.removeUmbController(this);
const host = this.#host;
(this.#host as unknown) = undefined;
host.removeUmbController(this);
}
super.destroy();
}
@@ -103,8 +103,7 @@ export class UmbContextProvider<BaseType = unknown, ResultType extends BaseType
// Note we are not removing the event listener in the hostDisconnected, therefor we do it here [NL].
this.#eventTarget?.removeEventListener(UMB_CONTEXT_REQUEST_EVENT_TYPE, this.#handleContextRequest);
this.#eventTarget?.removeEventListener(UMB_DEBUG_CONTEXT_EVENT_TYPE, this.#handleDebugContextRequest);
// We want to call a destroy method on the instance, if it has one.
(this.#instance as any)?.destroy?.();
// We do not want to call a destroy method on the instance, because maybe it should be re-provided later on. [NL]
this.#instance = undefined;
(this.#eventTarget as unknown) = undefined;
}
@@ -173,12 +173,13 @@ export class UmbPropertyEditorUIBlockListElement
this.observe(
this.#managerContext.layouts,
(layouts) => {
const validationMessagesToRemove: string[] = [];
const contentKeys = layouts.map((x) => x.contentKey);
this.#validationContext.messages.getMessagesOfPathAndDescendant('$.contentData').forEach((message) => {
// get the KEY from this string: $.contentData[?(@.key == 'KEY')]
const key = extractJsonQueryProps(message.path).key;
if (key && contentKeys.indexOf(key) === -1) {
this.#validationContext.messages.removeMessageByKey(message.key);
validationMessagesToRemove.push(message.key);
}
});
@@ -187,9 +188,12 @@ export class UmbPropertyEditorUIBlockListElement
// get the key from this string: $.settingsData[?(@.key == 'KEY')]
const key = extractJsonQueryProps(message.path).key;
if (key && settingsKeys.indexOf(key) === -1) {
this.#validationContext.messages.removeMessageByKey(message.key);
validationMessagesToRemove.push(message.key);
}
});
// Remove the messages after the loop to prevent changing the array while iterating over it.
this.#validationContext.messages.removeMessageByKeys(validationMessagesToRemove);
},
null,
);
@@ -67,7 +67,9 @@ export class UmbLocalizationRegistry {
if (!translations.length) return;
if (diff.length) {
const filteredTranslations = translations.filter((t) => diff.some((ext) => ext.meta.culture === t.$code));
const filteredTranslations = translations.filter((t) =>
diff.some((ext) => ext.meta.culture.toLowerCase() === t.$code),
);
umbLocalizationManager.registerManyLocalizations(filteredTranslations);
}
@@ -33,6 +33,26 @@ export class UmbValidationMessagesManager {
this.messages.subscribe((x) => console.log(logName, x));
}
getMessages(): Array<UmbValidationMessage> {
return this.#messages.getValue();
}
#updateLock = 0;
initiateChange() {
this.#updateLock++;
this.#messages.mute();
// TODO: When ready enable this code will enable handling a finish automatically by this implementation 'using myState.initiatePropertyValueChange()' (Relies on TS support of Using) [NL]
/*return {
[Symbol.dispose]: this.finishPropertyValueChange,
};*/
}
finishChange() {
this.#updateLock--;
if (this.#updateLock === 0) {
this.#messages.unmute();
}
}
getHasAnyMessages(): boolean {
return this.#messages.getValue().length !== 0;
}
@@ -75,7 +95,9 @@ export class UmbValidationMessagesManager {
if (this.#messages.getValue().find((x) => x.type === type && x.path === path && x.body === body)) {
return;
}
this.initiateChange();
this.#messages.appendOne({ type, key, path, body: body });
this.finishChange();
}
addMessages(type: UmbValidationMessageType, path: string, bodies: Array<string>): void {
@@ -86,27 +108,42 @@ export class UmbValidationMessagesManager {
const newBodies = bodies.filter(
(message) => existingMessages.find((x) => x.type === type && x.path === path && x.body === message) === undefined,
);
this.initiateChange();
this.#messages.append(newBodies.map((body) => ({ type, key: UmbId.new(), path, body })));
this.finishChange();
}
removeMessageByKey(key: string): void {
this.initiateChange();
this.#messages.removeOne(key);
this.finishChange();
}
removeMessageByKeys(keys: Array<string>): void {
if (keys.length === 0) return;
this.initiateChange();
this.#messages.filter((x) => keys.indexOf(x.key) === -1);
this.finishChange();
}
removeMessagesByType(type: UmbValidationMessageType): void {
this.initiateChange();
this.#messages.filter((x) => x.type !== type);
this.finishChange();
}
removeMessagesByPath(path: string): void {
this.initiateChange();
this.#messages.filter((x) => x.path !== path);
this.finishChange();
}
removeMessagesAndDescendantsByPath(path: string): void {
this.initiateChange();
this.#messages.filter((x) => MatchPathOrDescendantPath(x.path, path));
this.finishChange();
}
removeMessagesByTypeAndPath(type: UmbValidationMessageType, path: string): void {
//path = path.toLowerCase();
this.initiateChange();
this.#messages.filter((x) => !(x.type === type && x.path === path));
this.finishChange();
}
#translatePath(path: string): string | undefined {
@@ -124,6 +161,7 @@ export class UmbValidationMessagesManager {
#translators: Array<UmbValidationMessageTranslator> = [];
addTranslator(translator: UmbValidationMessageTranslator): void {
this.initiateChange();
if (this.#translators.indexOf(translator) === -1) {
this.#translators.push(translator);
}
@@ -137,6 +175,7 @@ export class UmbValidationMessagesManager {
this.#messages.updateOne(msg.key, { path: newPath });
}
}
this.finishChange();
}
removeTranslator(translator: UmbValidationMessageTranslator): void {
@@ -35,8 +35,6 @@ export class UmbFormControlValidator extends UmbControllerBase implements UmbVal
}
});
this.#control = formControl;
this.#control.addEventListener(UmbValidationInvalidEvent.TYPE, this.#setInvalid);
this.#control.addEventListener(UmbValidationValidEvent.TYPE, this.#setValid);
}
get isValid(): boolean {
@@ -82,12 +80,18 @@ export class UmbFormControlValidator extends UmbControllerBase implements UmbVal
override hostConnected(): void {
super.hostConnected();
this.#control.addEventListener(UmbValidationInvalidEvent.TYPE, this.#setInvalid);
this.#control.addEventListener(UmbValidationValidEvent.TYPE, this.#setValid);
if (this.#context) {
this.#context.addValidator(this);
}
}
override hostDisconnected(): void {
super.hostDisconnected();
if (this.#control) {
this.#control.removeEventListener(UmbValidationInvalidEvent.TYPE, this.#setInvalid);
this.#control.removeEventListener(UmbValidationValidEvent.TYPE, this.#setValid);
}
if (this.#context) {
this.#context.removeValidator(this);
// Remove any messages that this validator has added:
@@ -99,11 +103,9 @@ export class UmbFormControlValidator extends UmbControllerBase implements UmbVal
}
override destroy(): void {
super.destroy();
if (this.#control) {
this.#control.removeEventListener(UmbValidationInvalidEvent.TYPE, this.#setInvalid);
this.#control.removeEventListener(UmbValidationValidEvent.TYPE, this.#setValid);
this.#control = undefined as any;
}
super.destroy();
}
}
@@ -122,11 +122,12 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
this.observe(
parent.messages.messagesOfPathAndDescendant(dataPath),
(msgs) => {
this.messages.initiateChange();
//this.messages.appendMessages(msgs);
if (this.#parentMessages) {
// Remove the local messages that does not exist in the parent anymore:
const toRemove = this.#parentMessages.filter((msg) => !msgs.find((m) => m.key === msg.key));
this.#parent!.messages.removeMessageByKeys(toRemove.map((msg) => msg.key));
this.messages.removeMessageByKeys(toRemove.map((msg) => msg.key));
}
this.#parentMessages = msgs;
msgs.forEach((msg) => {
@@ -139,6 +140,7 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
// Notice, the local message uses the same key. [NL]
this.messages.addMessage(msg.type, path, msg.body, msg.key);
});
this.messages.finishChange();
},
'observeParentMessages',
);
@@ -147,6 +149,9 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
this.messages.messages,
(msgs) => {
if (!this.#parent) return;
this.#parent!.messages.initiateChange();
//this.messages.appendMessages(msgs);
if (this.#localMessages) {
// Remove the parent messages that does not exist locally anymore:
@@ -165,6 +170,8 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
// Notice, the parent message uses the same key. [NL]
this.#parent!.messages.addMessage(msg.type, path, msg.body, msg.key);
});
this.#parent!.messages.finishChange();
},
'observeLocalMessages',
);
@@ -172,6 +179,19 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
// Notice skipHost ^^, this is because we do not want it to consume it self, as this would be a match for this consumption, instead we will look at the parent and above. [NL]
}
override hostConnected(): void {
super.hostConnected();
if (this.#parent) {
this.#parent.addValidator(this);
}
}
override hostDisconnected(): void {
super.hostDisconnected();
if (this.#parent) {
this.#parent.removeValidator(this);
}
}
/**
* Get if this context is valid.
* Notice this does not verify the validity.
@@ -229,18 +249,27 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
() => false,
);
if (!this.messages) {
/*if (this.#validators.length === 0 && resultsStatus === false) {
throw new Error('No validators to validate, but validation failed');
}*/
if (this.messages === undefined) {
// This Context has been destroyed while is was validating, so we should not continue.
return Promise.reject();
}
const hasMessages = this.messages.getHasAnyMessages();
// If we have any messages then we are not valid, otherwise lets check the validation results: [NL]
// This enables us to keep client validations though UI is not present anymore — because the client validations got defined as messages. [NL]
const isValid = this.messages.getHasAnyMessages() ? false : resultsStatus;
const isValid = hasMessages ? false : resultsStatus;
this.#isValid = isValid;
if (isValid === false) {
/*if (hasMessages === false && resultsStatus === false) {
throw new Error('Missing validation messages to represent why a child validation context is invalid.');
}*/
// Focus first invalid element:
this.focusFirstInvalidElement();
return Promise.reject();
@@ -278,6 +307,7 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal
}
override destroy(): void {
this.#providerCtrl?.destroy();
this.#providerCtrl = undefined;
if (this.#parent) {
this.#parent.removeValidator(this);
@@ -4,8 +4,13 @@
* @param {string} path - the JSON path to the value that should be found
* @returns {unknown} - the found value.
*/
export function GetValueByJsonPath(data: unknown, path: string): unknown {
export function GetValueByJsonPath<ReturnType = unknown>(data: unknown, path: string): ReturnType | undefined {
if (path === '$') return data as ReturnType;
// strip $ from the path:
if (path.startsWith('$[')) {
return _GetNextArrayEntryFromPath(data as Array<unknown>, path.slice(2));
}
const strippedPath = path.startsWith('$.') ? path.slice(2) : path;
// get value from the path:
return GetNextPropertyValueFromPath(data, strippedPath);
@@ -33,49 +38,62 @@ function GetNextPropertyValueFromPath(data: any, path: string): any {
const value = data[key];
// if there is no rest of the path, return the value:
if (rest === undefined) return value;
// if the value is an array, get the value at the index:
if (Array.isArray(value)) {
// get the value until the next ']', the value can be anything in between the brackets:
const lookupEnd = rest.match(/\]/);
if (!lookupEnd) return undefined;
// get everything before the match:
const entryPointer = rest.slice(0, lookupEnd.index);
// check if the entryPointer is a JSON Path Filter ( starting with ?( and ending with ) ):
if (entryPointer.startsWith('?(') && entryPointer.endsWith(')')) {
// get the filter from the entryPointer:
// get the filter as a function:
const jsFilter = JsFilterFromJsonPathFilter(entryPointer);
// find the index of the value that matches the filter:
const index = value.findIndex(jsFilter[0]);
// if the index is -1, return undefined:
if (index === -1) return undefined;
// get the value at the index:
const data = value[index];
// Check for safety:
if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) {
return data;
}
// continue with the rest of the path:
return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data;
} else {
// get the value at the index:
const indexAsNumber = parseInt(entryPointer);
if (isNaN(indexAsNumber)) return undefined;
const data = value[indexAsNumber];
// Check for safety:
if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) {
return data;
}
// continue with the rest of the path:
return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data;
}
return _GetNextArrayEntryFromPath(value, rest);
} else {
// continue with the rest of the path:
return GetNextPropertyValueFromPath(value, rest);
}
}
/**
* @private
* @param {object} array - object to traverse for the value.
* @param {string} path - the JSON path to the value that should be found, notice without the starting '['
* @returns {unknown} - the found value.
*/
function _GetNextArrayEntryFromPath(array: Array<any>, path: string): any {
if (!array) return undefined;
// get the value until the next ']', the value can be anything in between the brackets:
const lookupEnd = path.match(/\]/);
if (!lookupEnd) return undefined;
// get everything before the match:
const entryPointer = path.slice(0, lookupEnd.index);
// check if the entryPointer is a JSON Path Filter ( starting with ?( and ending with ) ):
if (entryPointer.startsWith('?(') && entryPointer.endsWith(')')) {
// get the filter from the entryPointer:
// get the filter as a function:
const jsFilter = JsFilterFromJsonPathFilter(entryPointer);
// find the index of the value that matches the filter:
const index = array.findIndex(jsFilter[0]);
// if the index is -1, return undefined:
if (index === -1) return undefined;
// get the value at the index:
const entryData = array[index];
// Check for safety:
if (lookupEnd.index === undefined || lookupEnd.index + 1 >= path.length) {
return entryData;
}
// continue with the rest of the path:
return GetNextPropertyValueFromPath(entryData, path.slice(lookupEnd.index + 2)) ?? entryData;
} else {
// get the value at the index:
const indexAsNumber = parseInt(entryPointer);
if (isNaN(indexAsNumber)) return undefined;
const entryData = array[indexAsNumber];
// Check for safety:
if (lookupEnd.index === undefined || lookupEnd.index + 1 >= path.length) {
return entryData;
}
// continue with the rest of the path:
return GetNextPropertyValueFromPath(entryData, path.slice(lookupEnd.index + 2)) ?? entryData;
}
}
/**
* @param {string} filter - A JSON Query, limited to filtering features. Do not support other JSON PATH Query features.
* @returns {Array<(queryFilter: any) => boolean>} - An array of methods that returns true if the given items property value matches the value of the query.
@@ -2,6 +2,14 @@ import { expect } from '@open-wc/testing';
import { GetValueByJsonPath } from './json-path.function.js';
describe('UmbJsonPathFunctions', () => {
it('retrieves root when path is root', () => {
const data = { value: 'test' };
const result = GetValueByJsonPath(data, '$') as any;
expect(result).to.eq(data);
expect(result.value).to.eq('test');
});
it('retrieve property value', () => {
const result = GetValueByJsonPath({ value: 'test' }, '$.value');
@@ -34,4 +42,10 @@ describe('UmbJsonPathFunctions', () => {
expect(result).to.eq('test');
});
it('query of array in root', () => {
const result = GetValueByJsonPath([{ id: '123', value: 'test' }], "$[?(@.id == '123')].value");
expect(result).to.eq('test');
});
});
@@ -19,4 +19,10 @@ describe('ReplaceStartOfPath', () => {
expect(result).to.eq('$');
});
it('replaces the root character with root character', () => {
const result = ReplaceStartOfPath('$.start.test', '$', '$');
expect(result).to.eq('$.start.test');
});
});
@@ -74,7 +74,6 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
* @returns Promise that resolves to void when the validation is complete.
*/
public async validate(): Promise<Array<void>> {
//return this.validation.validate();
return Promise.all(this.#validationContexts.map((context) => context.validate()));
}
@@ -97,7 +96,15 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
async () => {
onValid().then(this.#completeSubmit, this.#rejectSubmit);
},
async () => {
async (/*error*/) => {
/*if (error) {
throw new Error(error);
}*/
// TODO: Implement developer-mode logging here. [NL]
console.warn(
'Validation failed because of these validation messages still begin present: ',
this.#validationContexts.flatMap((x) => x.messages.getMessages()),
);
onInvalid().then(this.#resolveSubmit, this.#rejectSubmit);
},
);
@@ -105,7 +112,10 @@ export abstract class UmbSubmittableWorkspaceContextBase<WorkspaceDataModelType>
return this.#submitPromise;
}
#rejectSubmit = () => {
#rejectSubmit = (/*error: any*/) => {
/*if (error) {
throw new Error(error);
}*/
if (this.#submitPromise) {
// TODO: Capture the validation contexts messages on open, and then reset to them in this case. [NL]
@@ -11,15 +11,16 @@ import type {
import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui';
import '../components/input-multi-url/index.js';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
/**
* @element umb-property-editor-ui-multi-url-picker
*/
@customElement('umb-property-editor-ui-multi-url-picker')
export class UmbPropertyEditorUIMultiUrlPickerElement extends UmbLitElement implements UmbPropertyEditorUiElement {
@property({ type: Array })
value: Array<UmbLinkPickerLink> = [];
export class UmbPropertyEditorUIMultiUrlPickerElement
extends UmbFormControlMixin<Array<UmbLinkPickerLink>, typeof UmbLitElement, undefined>(UmbLitElement)
implements UmbPropertyEditorUiElement
{
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
if (!config) return;
@@ -81,6 +82,7 @@ export class UmbPropertyEditorUIMultiUrlPickerElement extends UmbLitElement impl
this,
);
}
this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-multi-url')!);
}
#onChange(event: CustomEvent & { target: UmbInputMultiUrlElement }) {
@@ -1,6 +1,5 @@
import { UmbPropertyEditorUIMultipleTextStringElement } from '../multiple-text-string/property-editor-ui-multiple-text-string.element.js';
import { css, customElement, html, nothing, state, when } from '@umbraco-cms/backoffice/external/lit';
import { formatBytes } from '@umbraco-cms/backoffice/utils';
import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbTemporaryFileConfigRepository } from '@umbraco-cms/backoffice/temporary-file';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import type { UmbTemporaryFileConfigurationModel } from '@umbraco-cms/backoffice/temporary-file';
@@ -40,7 +39,8 @@ export class UmbPropertyEditorUIAcceptedUploadTypesElement
}
#addValidators(config: UmbTemporaryFileConfigurationModel) {
this._inputElement?.addValidator(
const inputElement = this.shadowRoot?.querySelector('umb-input-multiple-text-string');
inputElement?.addValidator(
'badInput',
() => {
let message = this.localize.term('validation_invalidExtensions');
@@ -53,7 +53,7 @@ export class UmbPropertyEditorUIAcceptedUploadTypesElement
return message;
},
() => {
const extensions = this._inputElement?.items;
const extensions = inputElement?.items;
if (!extensions) return false;
if (
config.allowedUploadedFileExtensions.length &&
@@ -69,49 +69,8 @@ export class UmbPropertyEditorUIAcceptedUploadTypesElement
);
}
#renderAcceptedTypes() {
if (!this._acceptedTypes.length && !this._disallowedTypes.length && !this._maxFileSize) {
return nothing;
}
return html`
<uui-box id="notice" headline=${this.localize.term('general_serverConfiguration')}>
<p><umb-localize key="media_noticeExtensionsServerOverride"></umb-localize></p>
${when(
this._acceptedTypes.length,
() => html`
<p>
<umb-localize key="validation_allowedExtensions"></umb-localize>
<strong>${this._acceptedTypes.join(', ')}</strong>
</p>
`,
)}
${when(
this._disallowedTypes.length,
() => html`
<p>
<umb-localize key="validation_disallowedExtensions"></umb-localize>
<strong>${this._disallowedTypes.join(', ')}</strong>
</p>
`,
)}
${when(
this._maxFileSize,
() => html`
<p>
${this.localize.term('media_maxFileSize')}
<strong title="${this.localize.number(this._maxFileSize!)} bytes"
>${formatBytes(this._maxFileSize!, { decimals: 2 })}</strong
>.
</p>
`,
)}
</uui-box>
`;
}
override render() {
return html`${this.#renderAcceptedTypes()} ${super.render()}`;
return html`${super.render()}`;
}
static override readonly styles = [
@@ -1,12 +1,8 @@
import { customElement, html, property, query, state } from '@umbraco-cms/backoffice/external/lit';
import { umbBindToValidation, UmbValidationContext } from '@umbraco-cms/backoffice/validation';
import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { umbBindToValidation, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbPropertyValueChangeEvent } from '@umbraco-cms/backoffice/property-editor';
import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property';
import {
UMB_SUBMITTABLE_WORKSPACE_CONTEXT,
UmbSubmittableWorkspaceContextBase,
} from '@umbraco-cms/backoffice/workspace';
import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { UmbInputMultipleTextStringElement } from '@umbraco-cms/backoffice/components';
import type {
@@ -18,10 +14,10 @@ import type {
* @element umb-property-editor-ui-multiple-text-string
*/
@customElement('umb-property-editor-ui-multiple-text-string')
export class UmbPropertyEditorUIMultipleTextStringElement extends UmbLitElement implements UmbPropertyEditorUiElement {
@property({ type: Array })
value?: Array<string>;
export class UmbPropertyEditorUIMultipleTextStringElement
extends UmbFormControlMixin<Array<string>, typeof UmbLitElement, undefined>(UmbLitElement)
implements UmbPropertyEditorUiElement
{
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
if (!config) return;
@@ -65,23 +61,12 @@ export class UmbPropertyEditorUIMultipleTextStringElement extends UmbLitElement
@state()
private _max = Infinity;
@query('#input', true)
protected _inputElement?: UmbInputMultipleTextStringElement;
protected _validationContext = new UmbValidationContext(this);
constructor() {
super();
this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => {
this._label = context.getLabel();
});
this.consumeContext(UMB_SUBMITTABLE_WORKSPACE_CONTEXT, (context) => {
if (context instanceof UmbSubmittableWorkspaceContextBase) {
context.addValidationContext(this._validationContext);
}
});
}
protected override firstUpdated() {
@@ -91,6 +76,7 @@ export class UmbPropertyEditorUIMultipleTextStringElement extends UmbLitElement
this,
);
}
this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-multiple-text-string')!);
}
#onChange(event: UmbChangeEvent) {
@@ -100,31 +86,18 @@ export class UmbPropertyEditorUIMultipleTextStringElement extends UmbLitElement
this.dispatchEvent(new UmbPropertyValueChangeEvent());
}
// Prevent valid events from bubbling outside the message element
#onValid(event: Event) {
event.stopPropagation();
}
// Prevent invalid events from bubbling outside the message element
#onInvalid(event: Event) {
event.stopPropagation();
}
override render() {
return html`
<umb-form-validation-message id="validation-message" @invalid=${this.#onInvalid} @valid=${this.#onValid}>
<umb-input-multiple-text-string
id="input"
max=${this._max}
min=${this._min}
.items=${this.value ?? []}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
@change=${this.#onChange}
${umbBindToValidation(this)}>
</umb-input-multiple-text-string>
</umb-form-validation-message>
<umb-input-multiple-text-string
max=${this._max}
min=${this._min}
.items=${this.value ?? []}
?disabled=${this.disabled}
?readonly=${this.readonly}
?required=${this.required}
@change=${this.#onChange}
${umbBindToValidation(this)}>
</umb-input-multiple-text-string>
`;
}
}
@@ -61,9 +61,8 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '
}
override set value(newValue: FormDataEntryValue | FormData) {
if (newValue === this.value) return;
super.value = newValue;
const newContent = typeof newValue === 'string' ? newValue : '';
super.value = newContent;
if (this.#editorRef && this.#editorRef.getContent() != newContent) {
this.#editorRef.setContent(newContent);
@@ -1,6 +1,6 @@
import type { UmbInputTinyMceElement } from '../../components/input-tiny-mce/input-tiny-mce.element.js';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbPropertyEditorUiRteElementBase, UMB_BLOCK_RTE_DATA_CONTENT_KEY } from '@umbraco-cms/backoffice/rte';
import { UmbPropertyEditorUiRteElementBase } from '@umbraco-cms/backoffice/rte';
import '../../components/input-tiny-mce/input-tiny-mce.element.js';
@@ -10,37 +10,32 @@ import '../../components/input-tiny-mce/input-tiny-mce.element.js';
@customElement('umb-property-editor-ui-tiny-mce')
export class UmbPropertyEditorUITinyMceElement extends UmbPropertyEditorUiRteElementBase {
#onChange(event: CustomEvent & { target: UmbInputTinyMceElement }) {
const value = typeof event.target.value === 'string' ? event.target.value : '';
const markup = typeof event.target.value === 'string' ? event.target.value : '';
// If we don't get any markup clear the property editor value.
if (value === '') {
if (markup === '') {
this.value = undefined;
this._fireChangeEvent();
return;
}
// Clone the DOM, to remove the classes and attributes on the original:
const div = document.createElement('div');
div.innerHTML = value;
// Loop through used, to remove the classes on these.
const blockEls = div.querySelectorAll(`umb-rte-block, umb-rte-block-inline`);
blockEls.forEach((blockEl) => {
blockEl.removeAttribute('contenteditable');
blockEl.removeAttribute('class');
});
const markup = div.innerHTML;
// Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup.
//const blockElements = editor.dom.select(`umb-rte-block, umb-rte-block-inline`);
const usedContentKeys = Array.from(blockEls).map((blockElement) =>
blockElement.getAttribute(UMB_BLOCK_RTE_DATA_CONTENT_KEY),
);
const usedContentKeys: string[] = [];
if (super.value) {
super.value = {
...super.value,
// Regex matching all block elements in the markup, and extracting the content key. It's the same as the one used on the backend.
const regex = new RegExp(
/<umb-rte-block(?:-inline)?(?: class="(?:.[^"]*)")? data-content-key="(?<key>.[^"]*)">(?:<!--Umbraco-Block-->)?<\/umb-rte-block(?:-inline)?>/gi,
);
let blockElement: RegExpExecArray | null;
while ((blockElement = regex.exec(markup)) !== null) {
if (blockElement.groups?.key) {
usedContentKeys.push(blockElement.groups.key);
}
}
if (this.value) {
this.value = {
...this.value,
markup: markup,
};
} else {
@@ -12,7 +12,7 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem
protected override firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.firstUpdated(_changedProperties);
this.addFormControlElement(this.shadowRoot?.querySelector('umb-input-tiptap') as UmbInputTiptapElement);
this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-tiptap') as UmbInputTiptapElement);
}
#onChange(event: CustomEvent & { target: UmbInputTiptapElement }) {
@@ -1,4 +1,3 @@
using Bogus.DataSets;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
@@ -23,8 +22,8 @@ public class ContentPublishingServiceTests : UmbracoIntegrationTestWithContent
{
private const string UnknownCulture = "ke-Ke";
private readonly DateTime _schedulePublishDate = DateTime.UtcNow.AddDays(1);
private readonly DateTime _scheduleUnPublishDate = DateTime.UtcNow.AddDays(2);
private readonly DateTime _schedulePublishDate = DateTime.UtcNow.AddDays(1).TruncateTo(DateTimeExtensions.DateTruncate.Second);
private readonly DateTime _scheduleUnPublishDate = DateTime.UtcNow.AddDays(2).TruncateTo(DateTimeExtensions.DateTruncate.Second);
[SetUp]
public new void Setup() => ContentRepositoryBase.ThrowOnWarning = true;
@@ -0,0 +1,91 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.TemporaryFile;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Attributes;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public class TemporaryFileServiceTests : UmbracoIntegrationTest
{
private ITemporaryFileService TemporaryFileService => GetRequiredService<ITemporaryFileService>();
public static void ConfigureAllowedUploadedFileExtensions(IUmbracoBuilder builder)
{
builder.Services.Configure<ContentSettings>(config =>
config.AllowedUploadedFileExtensions = ["txt"]);
}
[Test]
[ConfigureBuilder(ActionName = nameof(ConfigureAllowedUploadedFileExtensions))]
public async Task Can_Create_Get_And_Delete_Temporary_File()
{
var key = Guid.NewGuid();
const string FileName = "test.txt";
const string FileContents = "test";
var model = new CreateTemporaryFileModel
{
FileName = FileName,
Key = key,
OpenReadStream = () =>
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(FileContents);
writer.Flush();
stream.Position = 0;
return stream;
}
};
var createAttempt = await TemporaryFileService.CreateAsync(model);
Assert.IsTrue(createAttempt.Success);
TemporaryFileModel? fileModel = await TemporaryFileService.GetAsync(key);
Assert.IsNotNull(fileModel);
Assert.AreEqual(key, fileModel.Key);
Assert.AreEqual(FileName, fileModel.FileName);
using (var reader = new StreamReader(fileModel.OpenReadStream()))
{
string fileContents = reader.ReadToEnd();
Assert.AreEqual(FileContents, fileContents);
}
var deleteAttempt = await TemporaryFileService.DeleteAsync(key);
Assert.IsTrue(createAttempt.Success);
fileModel = await TemporaryFileService.GetAsync(key);
Assert.IsNull(fileModel);
}
[Test]
[ConfigureBuilder(ActionName = nameof(ConfigureAllowedUploadedFileExtensions))]
public async Task Cannot_Create_File_Outside_Of_Temporary_Files_Root()
{
var key = Guid.NewGuid();
const string FileName = "../test.txt";
var model = new CreateTemporaryFileModel
{
FileName = FileName,
Key = key,
OpenReadStream = () =>
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.Write(string.Empty);
writer.Flush();
stream.Position = 0;
return stream;
}
};
var createAttempt = await TemporaryFileService.CreateAsync(model);
Assert.IsFalse(createAttempt.Success);
Assert.AreEqual(TemporaryFileOperationStatus.InvalidFileName, createAttempt.Status);
}
}
@@ -527,7 +527,7 @@ public class LocksTests : UmbracoIntegrationTest
}
}
[Retry(3)] // TODO make this test non-flaky.
[NUnit.Framework.Ignore("This test is very flaky, and is stopping our nightlys")]
[Test]
public void Read_Lock_Waits_For_Write_Lock()
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "15.3.0-rc2",
"version": "15.3.1",
"assemblyVersion": {
"precision": "build"
},