Skip to content
Merged
13 changes: 11 additions & 2 deletions src/Umbraco.Core/Services/PreviewService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,24 @@ public async Task<bool> TryEnterPreviewAsync(IUser user)

if (attempt.Success)
{
_cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, attempt.Result!, true, true, "None");
// Preview cookies must use SameSite=None and Secure=true to support cross-site scenarios
// (e.g., when the backoffice is on a different domain/port than the frontend during development).
// SameSite=None requires Secure=true per browser specifications.
_cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, attempt.Result!, httpOnly: true, secure: true, sameSiteMode: "None", expires: null);
}

return attempt.Success;
}

public Task EndPreviewAsync()
{
_cookieManager.ExpireCookie(Constants.Web.PreviewCookieName);
// Expire the cookie with the same attributes used when creating it
// This ensures the browser properly removes the cookie
_cookieManager.ExpireCookie(
Constants.Web.PreviewCookieName,
httpOnly: true,
secure: true,
sameSiteMode: "None");
return Task.CompletedTask;
}

Expand Down
34 changes: 34 additions & 0 deletions src/Umbraco.Core/Web/ICookieManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,24 @@ public interface ICookieManager
/// Expires the cookie with the specified name.
/// </summary>
/// <param name="cookieName">The cookie name.</param>
[Obsolete("Please use the overload that accepts httpOnly, secure and sameSiteMode parameters. This will be removed in Umbraco 19.")]
void ExpireCookie(string cookieName);

/// <summary>
/// Expires the cookie with the specified name and security attributes.
/// </summary>
/// <param name="cookieName">The cookie name.</param>
/// <param name="httpOnly">Indicates whether the cookie should be marked as HTTP only.</param>
/// <param name="secure">Indicates whether the cookie should be marked as secure.</param>
/// <param name="sameSiteMode">Indicates the cookie's same site status.</param>
/// <remarks>
/// The value provided by <paramref name="sameSiteMode"/> should match the enum values available from
/// Microsoft.AspNetCore.Http.SameSiteMode.
/// This hasn't been used as the parameter directly to avoid a dependency on Microsoft.AspNetCore.Http in
/// the core project.
/// </remarks>
void ExpireCookie(string cookieName, bool httpOnly, bool secure, string sameSiteMode);

/// <summary>
/// Gets the value of the cookie with the specified name.
/// </summary>
Expand All @@ -31,8 +47,26 @@ public interface ICookieManager
/// This hasn't been used as the parameter directly to avoid a dependency on Microsoft.AspNetCore.Http in
/// the core project.
/// </remarks>
[Obsolete("Please use the overload that accepts an expires parameter. This will be removed in Umbraco 19.")]
void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode);

/// <summary>
/// Sets the value of a cookie with the specified name and expiration.
/// </summary>
/// <param name="cookieName">The cookie name.</param>
/// <param name="value">The cookie value.</param>
/// <param name="httpOnly">Indicates whether the created cookie should be marked as HTTP only.</param>
/// <param name="secure">Indicates whether the created cookie should be marked as secure.</param>
/// <param name="sameSiteMode">Indicates the created cookie's same site status.</param>
/// <param name="expires">Optional expiration date for the cookie. If null, the cookie is a session cookie.</param>
/// <remarks>
/// The value provided by <paramref name="sameSiteMode"/> should match the enum values available from
/// Microsoft.AspNetCore.Http.SameSiteMode.
/// This hasn't been used as the parameter directly to avoid a dependency on Microsoft.AspNetCore.Http in
/// the core project.
/// </remarks>
void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode, DateTimeOffset? expires);

/// <summary>
/// Determines whether a cookie with the specified name exists.
/// </summary>
Expand Down
32 changes: 23 additions & 9 deletions src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;

Check warning on line 1 in src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Primitive Obsession

In this module, 90.0% of all function arguments are primitive types, threshold = 30.0%. The functions in this file have too many primitive types (e.g. int, double, float) in their function argument lists. Using many primitive types lead to the code smell Primitive Obsession. Avoid adding more primitive arguments.

Check warning on line 1 in src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: String Heavy Function Arguments

In this module, 60.0% of all arguments to its 8 functions are strings. The threshold for string arguments is 39.0%. The functions in this file have a high ratio of strings as arguments. Avoid adding more.
using Umbraco.Cms.Core.Web;

namespace Umbraco.Cms.Web.Common.AspNetCore;
Expand All @@ -18,6 +18,7 @@
_httpContextAccessor = httpContextAccessor;

/// <inheritdoc/>
[Obsolete("Please use the overload that accepts httpOnly, secure and sameSiteMode parameters. This will be removed in Umbraco 19.")]
public void ExpireCookie(string cookieName)
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
Expand All @@ -38,22 +39,35 @@
});
}

/// <inheritdoc/>
public void ExpireCookie(string cookieName, bool httpOnly, bool secure, string sameSiteMode)
=> SetCookieValue(cookieName, string.Empty, httpOnly, secure, sameSiteMode, DateTimeOffset.Now.AddYears(-1));

/// <inheritdoc/>
public string? GetCookieValue(string cookieName) => _httpContextAccessor.HttpContext?.Request.Cookies[cookieName];

/// <inheritdoc/>
[Obsolete("Please use the overload that accepts an expires parameter. This will be removed in Umbraco 19.")]
public void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode)
=> SetCookieValue(cookieName, value, httpOnly, secure, sameSiteMode, null);

/// <inheritdoc/>
public void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode, DateTimeOffset? expires)
{
SameSiteMode sameSiteModeValue = ParseSameSiteMode(sameSiteMode);
_httpContextAccessor.HttpContext?.Response.Cookies.Append(
cookieName,
value,
new CookieOptions
{
HttpOnly = httpOnly,
SameSite = sameSiteModeValue,
Secure = secure,
});
var options = new CookieOptions
{
HttpOnly = httpOnly,
SameSite = sameSiteModeValue,
Secure = secure,
};

if (expires.HasValue)
{
options.Expires = expires.Value;
}

_httpContextAccessor.HttpContext?.Response.Cookies.Append(cookieName, value, options);
}

private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
#documentSegmentRepository = new UmbDocumentSegmentRepository(this);
#actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE;
#localize = new UmbLocalizationController(this);
#previewWindow?: WindowProxy | null = null;
#previewWindowDocumentId?: string | null = null;

constructor(host: UmbControllerHost) {
super(host, {
Expand Down Expand Up @@ -343,7 +345,20 @@
await this.performCreateOrUpdate(variantIds, saveData);
}

// Get the preview URL from the server.
// Check if preview window is still open and showing the same document
// If so, just focus it and let SignalR handle the refresh
try {
if (this.#previewWindow && !this.#previewWindow.closed && this.#previewWindowDocumentId === unique) {

Check warning on line 351 in src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ New issue: Complex Conditional

UmbDocumentWorkspaceContext.handleSaveAndPreview has 1 complex conditionals with 2 branches, threshold = 2. A complex conditional is an expression inside a branch (e.g. if, for, while) which consists of multiple, logical operators such as AND/OR. The more logical operators in an expression, the more severe the code smell.
this.#previewWindow.focus();
return;
}
} catch {
// Window reference is stale, continue to create new preview session
this.#previewWindow = null;
this.#previewWindowDocumentId = null;
}

// Preview not open, create new preview session and open window
const previewRepository = new UmbPreviewRepository(this);
const previewUrlData = await previewRepository.getPreviewUrl(
unique,
Expand All @@ -353,8 +368,12 @@
);

if (previewUrlData.url) {
const previewWindow = window.open(previewUrlData.url, `umbpreview-${unique}`);
previewWindow?.focus();
// Add cache-busting parameter to ensure the preview tab reloads with the new preview session
const previewUrl = new URL(previewUrlData.url, window.location.origin);
previewUrl.searchParams.set('rnd', Date.now().toString());
this.#previewWindow = window.open(previewUrl.toString(), `umbpreview-${unique}`);
this.#previewWindowDocumentId = unique;
this.#previewWindow?.focus();
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UmbPreviewRepository } from '../repository/index.js';

Check notice on line 1 in src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

✅ Getting better: Overall Code Complexity

The mean cyclomatic complexity decreases from 5.08 to 5.00, threshold = 4. This file has many conditional statements (e.g. if, for, while) across its implementation, leading to lower code health. Avoid adding more conditionals.
import { UMB_PREVIEW_CONTEXT } from './preview.context-token.js';
import { HubConnectionBuilder } from '@umbraco-cms/backoffice/external/signalr';
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
Expand Down Expand Up @@ -200,18 +200,15 @@
async exitPreview() {
await this.#previewRepository.exit();

// Stop SignalR connection without waiting - window will close anyway
if (this.#connection) {
await this.#connection.stop();
this.#connection.stop();
this.#connection = undefined;
}

let url = await this.#getPublishedUrl();

if (!url) {
url = this.#previewUrl.getValue() as string;
}

window.location.replace(url);
// Close the preview window
// This ensures that subsequent "Save and Preview" actions will create a new preview session
window.close();
}

iframeLoaded(iframe: HTMLIFrameElement) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export class UmbPreviewEnvironmentsElement extends UmbLitElement {
);

if (previewUrlData.url) {
const previewWindow = window.open(previewUrlData.url, `umbpreview-${this._unique}`);
// Add cache-busting parameter to ensure the preview tab reloads with the new preview session
const previewUrl = new URL(previewUrlData.url, window.location.origin);
previewUrl.searchParams.set('rnd', Date.now().toString());
const previewWindow = window.open(previewUrl.toString(), `umbpreview-${this._unique}`);
previewWindow?.focus();
return;
}
Expand Down
Loading