diff --git a/Directory.Build.props b/Directory.Build.props index 09dd04749dd2..28035831ba21 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,8 +40,8 @@ false - false - 16.0.0 + true + 17.0.0 true true diff --git a/src/Umbraco.Cms.Api.Common/CLAUDE.md b/src/Umbraco.Cms.Api.Common/CLAUDE.md index a40488c9fa7d..9829f32bbcde 100644 --- a/src/Umbraco.Cms.Api.Common/CLAUDE.md +++ b/src/Umbraco.Cms.Api.Common/CLAUDE.md @@ -23,7 +23,7 @@ Shared infrastructure for Umbraco CMS REST APIs (Management and Delivery). - `Umbraco.Core` - Domain models and service contracts - `Umbraco.Web.Common` - Web functionality -### Project Structure (45 files) +### Project Structure (46 files) ``` Umbraco.Cms.Api.Common/ @@ -55,20 +55,7 @@ Umbraco.Cms.Api.Common/ ## 2. Commands -```bash -# Build -dotnet build src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj - -# Pack for NuGet -dotnet pack src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj -c Release - -# Run tests (integration tests in consuming APIs) -dotnet test tests/Umbraco.Tests.Integration/ - -# Check for outdated/vulnerable packages -dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --outdated -dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --vulnerable -``` +See "Quick Reference" section at bottom for common commands. --- @@ -89,20 +76,22 @@ public class SchemaIdHandler : ISchemaIdHandler **Why**: Management and Delivery APIs can customize schema/operation ID generation. -### Schema ID Sanitization (OpenApi/SchemaIdHandler.cs:32) +### Schema ID Sanitization (OpenApi/SchemaIdHandler.cs:24-29, 32) ```csharp -// Remove invalid characters to prevent OpenAPI generation errors -return Regex.Replace(name, @"[^\w]", string.Empty); - -// Add "Model" suffix to avoid TypeScript name clashes (line 24) +// Add "Model" suffix to avoid TypeScript name clashes (lines 24-29) if (name.EndsWith("Model") == false) { + // because some models names clash with common classes in TypeScript (i.e. Document), + // we need to add a "Model" postfix to all models name = $"{name}Model"; } + +// Remove invalid characters to prevent OpenAPI generation errors (line 32) +return Regex.Replace(name, @"[^\w]", string.Empty); ``` -### Polymorphic Deserialization (Serialization/UmbracoJsonTypeInfoResolver.cs:31-34) +### Polymorphic Deserialization (Serialization/UmbracoJsonTypeInfoResolver.cs:29-35) ```csharp // IMPORTANT: do NOT return an empty enumerable here. it will cause nullability to fail on reference @@ -144,8 +133,10 @@ dotnet test tests/Umbraco.Tests.Integration/ ### Key Configuration (DependencyInjection/UmbracoBuilderAuthExtensions.cs) -**Reference Tokens over JWT** (line 73-74): +**Reference Tokens over JWT** (line 76-80): ```csharp +// Enable reference tokens +// - see https://documentation.openiddict.com/configuration/token-storage.html options .UseReferenceAccessTokens() .UseReferenceRefreshTokens(); @@ -153,23 +144,44 @@ options **Why**: More secure (revocable), better for load balancing, uses ASP.NET Core Data Protection. -**Token Lifetime** (line 84-85): +**Token Lifetime** (line 88-91): ```csharp -// Access token: 25% of refresh token lifetime +// Make the access token lifetime 25% of the refresh token lifetime options.SetAccessTokenLifetime(new TimeSpan(timeOut.Ticks / 4)); options.SetRefreshTokenLifetime(timeOut); ``` -**PKCE Required** (line 54-56): +**PKCE Required** (line 59-63): ```csharp +// Enable authorization code flow with PKCE options .AllowAuthorizationCodeFlow() - .RequireProofKeyForCodeExchange(); + .RequireProofKeyForCodeExchange() + .AllowRefreshTokenFlow(); +``` + +**Endpoints**: Backoffice `/umbraco/management/api/v1/security/*`, Member `/umbraco/member/api/v1/security/*` + +### Secure Cookie-Based Token Storage (v17+) + +**Implementation** (DependencyInjection/HideBackOfficeTokensHandler.cs): + +Back-office tokens are hidden from client-side JavaScript via HTTP-only cookies: + +```csharp +private const string AccessTokenCookieKey = "__Host-umbAccessToken"; +private const string RefreshTokenCookieKey = "__Host-umbRefreshToken"; + +// Tokens are encrypted via Data Protection and stored in cookies +SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken); +context.Response.AccessToken = "[redacted]"; // Client sees redacted value ``` -**Endpoints**: -- Backoffice: `/umbraco/management/api/v1/security/*` -- Member: `/umbraco/member/api/v1/security/*` +**Key Security Features** (lines 143-165): `HttpOnly`, `IsEssential`, `Path="/"`, `Secure` (HTTPS), `__Host-` prefix + +**Configuration**: `BackOfficeTokenCookieSettings.Enabled` (default: true in v17+) + +**Implications**: Client-side cannot access tokens; encrypted with Data Protection; load balancing needs shared key ring; API requests need `credentials: include` --- @@ -183,10 +195,9 @@ options ```csharp catch (NotSupportedException exception) { - // This happens when trying to deserialize to an interface, - // without sending the $type as part of the request - context.ModelState.TryAddModelException(string.Empty, - new InputFormatterException(exception.Message, exception)); + // This happens when trying to deserialize to an interface, without sending the $type as part of the request + context.ModelState.TryAddModelException(string.Empty, new InputFormatterException(exception.Message, exception)); + return await InputFormatterResult.FailureAsync(); } ``` @@ -196,23 +207,23 @@ catch (NotSupportedException exception) **Issue**: Type names like `Document` clash with TypeScript built-ins. -**Solution** (OpenApi/SchemaIdHandler.cs:24-29): -```csharp -if (name.EndsWith("Model") == false) -{ - // Add "Model" postfix to all models - name = $"{name}Model"; -} -``` +**Solution**: Add "Model" suffix (OpenApi/SchemaIdHandler.cs:24-29) ### Generic Type Handling **Issue**: `PagedViewModel` needs flattened schema name. -**Solution** (OpenApi/SchemaIdHandler.cs:41-49): +**Solution** (OpenApi/SchemaIdHandler.cs:41-50): ```csharp -// Turns "PagedViewModel" into "PagedRelationItemModel" -return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}"; +private string HandleGenerics(string name, Type type) +{ + if (!type.IsGenericType) + return name; + + // use attribute custom name or append the generic type names + // turns "PagedViewModel" into "PagedRelationItem" + return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}"; +} ``` --- @@ -258,16 +269,6 @@ return BadRequest(problemDetails); ## 8. Project-Specific Notes -### Why Reference Tokens Instead of JWT? - -**Decision**: Use `UseReferenceAccessTokens()` and ASP.NET Core Data Protection. - -**Tradeoffs**: -- ✅ **Pros**: Revocable, simpler key management, better security -- ❌ **Cons**: Requires database lookup (slower than JWT), needs shared Data Protection key ring - -**Load Balancing Requirement**: All servers must share the same Data Protection key ring and application name. - ### Why Virtual Handlers? **Decision**: Make `SchemaIdHandler`, `OperationIdHandler`, etc. virtual. @@ -278,12 +279,7 @@ return BadRequest(problemDetails); ### Performance: Subtype Caching -**Implementation** (Serialization/UmbracoJsonTypeInfoResolver.cs:14): -```csharp -private readonly ConcurrentDictionary> _subTypesCache = new(); -``` - -**Why**: Reflection is expensive. Cache discovered subtypes to avoid repeated `ITypeFinder.FindClassesOfType()` calls. +**Why**: Cache discovered subtypes (UmbracoJsonTypeInfoResolver.cs:14) to avoid expensive reflection calls ### Known Limitations @@ -318,23 +314,11 @@ private readonly ConcurrentDictionary> _subTypesCache = new(); ### Configuration -**HTTPS** (Configuration/ConfigureOpenIddict.cs:14): -```csharp -// Disable transport security requirement for local development -options.DisableTransportSecurityRequirement = _globalSettings.Value.UseHttps is false; -``` - -**⚠️ Warning**: Never disable HTTPS in production. +**HTTPS**: `DisableTransportSecurityRequirement` for local dev only (ConfigureOpenIddict.cs:14). **Warning**: Never disable in production. -### Usage by Consuming APIs +### Usage Pattern -**Registration Pattern**: -```csharp -// In Umbraco.Cms.Api.Management or Umbraco.Cms.Api.Delivery -builder - .AddUmbracoApiOpenApiUI() // Swagger + custom handlers - .AddUmbracoOpenIddict(); // OAuth 2.0 authentication -``` +Consuming APIs call `builder.AddUmbracoApiOpenApiUI().AddUmbracoOpenIddict()` --- @@ -343,7 +327,7 @@ builder ### Essential Commands ```bash -# Build +# Build project dotnet build src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj # Pack for NuGet @@ -351,6 +335,10 @@ dotnet pack src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj -c Release # Test via integration tests dotnet test tests/Umbraco.Tests.Integration/ + +# Check packages +dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --outdated +dotnet list src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj package --vulnerable ``` ### Key Classes @@ -361,13 +349,14 @@ dotnet test tests/Umbraco.Tests.Integration/ | `SchemaIdHandler` | Generate OpenAPI schema IDs | OpenApi/SchemaIdHandler.cs | | `UmbracoJsonTypeInfoResolver` | Polymorphic JSON serialization | Serialization/UmbracoJsonTypeInfoResolver.cs | | `UmbracoBuilderAuthExtensions` | Configure OpenIddict | DependencyInjection/UmbracoBuilderAuthExtensions.cs | +| `HideBackOfficeTokensHandler` | Secure cookie-based token storage | DependencyInjection/HideBackOfficeTokensHandler.cs | | `PagedViewModel` | Generic pagination model | ViewModels/Pagination/PagedViewModel.cs | ### Important Files - `Umbraco.Cms.Api.Common.csproj` - Project dependencies -- `DependencyInjection/UmbracoBuilderApiExtensions.cs` - OpenAPI registration (line 12-30) -- `DependencyInjection/UmbracoBuilderAuthExtensions.cs` - OpenIddict setup (line 19-144) +- `DependencyInjection/UmbracoBuilderApiExtensions.cs` - OpenAPI registration (line 12-31) +- `DependencyInjection/UmbracoBuilderAuthExtensions.cs` - OpenIddict setup (line 20-183) - `Security/Paths.cs` - API endpoint path constants ### Getting Help diff --git a/src/Umbraco.Cms.Api.Delivery/CLAUDE.md b/src/Umbraco.Cms.Api.Delivery/CLAUDE.md new file mode 100644 index 000000000000..a29af215f401 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/CLAUDE.md @@ -0,0 +1,382 @@ +# Umbraco.Cms.Api.Delivery + +Headless content delivery REST API for Umbraco CMS. Enables frontend applications to fetch published content, media, and member-protected resources. + +--- + +## 1. Architecture + +**Type**: Class Library (NuGet Package) +**Target Framework**: .NET 10.0 +**Purpose**: Content Delivery API for headless CMS scenarios + +### Key Technologies + +- **ASP.NET Core** - Web framework +- **OpenIddict** - Member authentication (OAuth 2.0) +- **Asp.Versioning** - API versioning (V1, V2) +- **Output Caching** - Configurable response caching +- **Examine/Lucene** - Content querying + +### Dependencies + +- `Umbraco.Cms.Api.Common` - Shared API infrastructure (OpenAPI, auth) +- `Umbraco.Web.Common` - Web functionality + +### Project Structure (86 files) + +``` +Umbraco.Cms.Api.Delivery/ +├── Controllers/ +│ ├── Content/ # Content endpoints (by ID, route, query) +│ ├── Media/ # Media endpoints (by ID, path, query) +│ └── Security/ # Member auth (authorize, token, signout) +├── Querying/ +│ ├── Filters/ # ContentType, Name, CreateDate, UpdateDate +│ ├── Selectors/ # Ancestors, Children, Descendants +│ └── Sorts/ # Name, CreateDate, UpdateDate, Level, SortOrder +├── Indexing/ # Lucene index field handlers +├── Services/ # Business logic and query building +├── Caching/ # Output cache policies +├── Rendering/ # Output expansion strategies +├── Configuration/ # Swagger configuration +└── Filters/ # Action filters (access, validation) +``` + +### Design Patterns + +1. **Strategy Pattern** - Query handlers (`ISelectorHandler`, `IFilterHandler`, `ISortHandler`) +2. **Factory Pattern** - `ApiContentQueryFactory` builds Examine queries +3. **Template Method** - `ContentApiControllerBase` for shared controller logic +4. **Options Pattern** - `DeliveryApiSettings` for all configuration + +--- + +## 2. Commands + +See "Quick Reference" section at bottom for common commands. + +--- + +## 3. Key Patterns + +### API Versioning (V1 vs V2) + +**V1** (legacy) and **V2** (current) coexist. Key difference is output expansion: + +```csharp +// DependencyInjection/UmbracoBuilderExtensions.cs:49-52 +// V1 uses RequestContextOutputExpansionStrategy +// V2+ uses RequestContextOutputExpansionStrategyV2 +return apiVersion.MajorVersion == 1 + ? provider.GetRequiredService() + : provider.GetRequiredService(); +``` + +**Why V2**: Improved `expand` and `fields` query parameter parsing (tree-based). + +### Query System Architecture + +Content querying flows through handlers registered in DI: + +1. **Selectors** (`fetch` parameter): `ancestors:id`, `children:id`, `descendants:id` +2. **Filters** (`filter[]` parameter): `contentType:alias`, `name:value`, `createDate>2024-01-01` +3. **Sorts** (`sort[]` parameter): `name:asc`, `createDate:desc`, `level:asc` + +```csharp +// Services/ApiContentQueryService.cs:91-96 +ISelectorHandler? selectorHandler = _selectorHandlers.FirstOrDefault(h => h.CanHandle(fetch)); +return selectorHandler?.BuildSelectorOption(fetch); +``` + +### Path Decoding Workaround + +ASP.NET Core doesn't decode forward slashes in route parameters: + +```csharp +// Controllers/DeliveryApiControllerBase.cs:21-31 +// OpenAPI clients URL-encode paths, but ASP.NET Core doesn't decode "/" +// See https://github.com/dotnet/aspnetcore/issues/11544 +if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase)) +{ + path = WebUtility.UrlDecode(path); +} +``` + +--- + +## 4. Testing + +**Location**: No direct tests - tested via integration tests in test projects + +```bash +dotnet test tests/Umbraco.Tests.Integration/ --filter "FullyQualifiedName~Delivery" +``` + +**Internals exposed to** (csproj lines 28-36): +- `Umbraco.Tests.UnitTests` +- `Umbraco.Tests.Integration` +- `DynamicProxyGenAssembly2` (for mocking) + +**Focus areas**: +- Query parsing (selectors, filters, sorts) +- Member authentication flows +- Output caching behavior +- Protected content access + +--- + +## 5. Security & Access Control + +### Three Access Modes + +```csharp +// Services/ApiAccessService.cs:21-27 +public bool HasPublicAccess() => _deliveryApiSettings.PublicAccess || HasValidApiKey(); +public bool HasPreviewAccess() => HasValidApiKey(); +public bool HasMediaAccess() => _deliveryApiSettings is { PublicAccess: true, Media.PublicAccess: true } || HasValidApiKey(); +``` + +**Access levels**: +1. **Public** - No authentication required (if enabled) +2. **API Key** - Via `Api-Key` header +3. **Preview** - Always requires API key + +### Member Authentication + +OpenIddict-based OAuth 2.0 for member-protected content: + +**Flows supported** (Controllers/Security/MemberController.cs): +- Authorization Code + PKCE (line 53) +- Client Credentials (line 112) +- Refresh Token (line 98) + +**Endpoints**: +- `GET /umbraco/delivery/api/v1/security/member/authorize` +- `POST /umbraco/delivery/api/v1/security/member/token` +- `GET /umbraco/delivery/api/v1/security/member/signout` + +**Scopes**: Only `openid` and `offline_access` allowed for members (line 220-222) + +### Protected Content + +Member access checked via `ProtectedAccess` model: + +```csharp +// Controllers/Content/QueryContentApiController.cs:56-57 +ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync(); +Attempt, ApiContentQueryOperationStatus> queryAttempt = + _apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take); +``` + +--- + +## 6. Output Caching + +### Cache Policy Configuration + +```csharp +// DependencyInjection/UmbracoBuilderExtensions.cs:120-136 +// Content and Media have separate cache durations +options.AddPolicy( + Constants.DeliveryApi.OutputCache.ContentCachePolicy, + new DeliveryApiOutputCachePolicy( + outputCacheSettings.ContentDuration, + new StringValues([AcceptLanguage, AcceptSegment, StartItem]))); +``` + +**Cache invalidation conditions** (Caching/DeliveryApiOutputCachePolicy.cs:31): +```csharp +// Never cache preview or non-public access +context.EnableOutputCaching = requestPreviewService.IsPreview() is false + && apiAccessService.HasPublicAccess(); +``` + +**Vary by headers**: `Accept-Language`, `Accept-Segment`, `Start-Item` + +--- + +## 7. Edge Cases & Known Issues + +### Technical Debt (TODOs in codebase) + +1. **V1 Removal Pending** (4 locations): + - `DependencyInjection/UmbracoBuilderExtensions.cs:98` - FIXME: remove matcher policy + - `Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs:11` - FIXME: remove class + - `Filters/SwaggerDocumentationFilterBase.cs:79,83` - FIXME: remove V1 swagger docs + +2. **Obsolete Reference Warnings** (csproj:9-13): + - `ASP0019` - IHeaderDictionary.Append usage + - `CS0618/CS0612` - Obsolete member references + +### Empty Query Results + +Query service returns empty results (not errors) for invalid options: + +```csharp +// Services/ApiContentQueryService.cs:54-78 +// Invalid selector/filter/sort returns fail status with empty result +return Attempt.FailWithStatus(ApiContentQueryOperationStatus.SelectorOptionNotFound, emptyResult); +``` + +### Start Item Fallback + +When no `fetch` parameter provided, uses start item or all content: + +```csharp +// Services/ApiContentQueryService.cs:99-112 +if (_requestStartItemProviderAccessor.TryGetValue(out IRequestStartItemProvider? requestStartItemProvider)) +{ + IPublishedContent? startItem = requestStartItemProvider.GetStartItem(); + // Use descendants of start item +} +return _apiContentQueryProvider.AllContentSelectorOption(); // Fallback to all +``` + +--- + +## 8. Project-Specific Notes + +### V1 vs V2 Differences + +| Feature | V1 | V2 | +|---------|----|----| +| Output expansion | Basic | Tree-based parsing | +| `expand` parameter | Flat list | Nested syntax | +| `fields` parameter | Limited | Full property selection | +| Default expansion strategy | `RequestContextOutputExpansionStrategy` | `RequestContextOutputExpansionStrategyV2` | + +**Migration note**: V1 is deprecated; plan removal when V17+ drops V1 support. + +### JSON Configuration + +Delivery API has its own JSON options (distinct from Management API): + +```csharp +// DependencyInjection/UmbracoBuilderExtensions.cs:82-88 +.AddJsonOptions(Constants.JsonOptionsNames.DeliveryApi, options => +{ + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.TypeInfoResolver = new DeliveryApiJsonTypeResolver(); + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); +``` + +### Member Token Revocation + +Tokens automatically revoked on member changes: + +```csharp +// DependencyInjection/UmbracoBuilderExtensions.cs:93-96 +builder.AddNotificationAsyncHandler(); +builder.AddNotificationAsyncHandler(); +builder.AddNotificationAsyncHandler(); +builder.AddNotificationAsyncHandler(); +``` + +### External Dependencies + +**Examine/Lucene** (via Core): +- Powers content querying +- Selector/Filter/Sort handlers build Lucene queries + +**OpenIddict** (via Api.Common): +- Member OAuth 2.0 authentication +- Reference tokens (not JWT) + +### Configuration (appsettings.json) + +```json +{ + "Umbraco": { + "CMS": { + "DeliveryApi": { + "Enabled": true, + "PublicAccess": true, + "ApiKey": "your-api-key", + "Media": { + "Enabled": true, + "PublicAccess": true + }, + "MemberAuthorization": { + "AuthorizationCodeFlow": { "Enabled": true }, + "ClientCredentialsFlow": { "Enabled": false } + }, + "OutputCache": { + "Enabled": true, + "ContentDuration": "00:01:00", + "MediaDuration": "00:01:00" + } + } + } + } +} +``` + +### API Endpoints Summary + +**Content** (`/umbraco/delivery/api/v2/content`): +- `GET /item/{id}` - Single content by GUID +- `GET /item/{path}` - Single content by route +- `GET /items` - Multiple by IDs +- `GET /` - Query with fetch/filter/sort + +**Media** (`/umbraco/delivery/api/v2/media`): +- `GET /item/{id}` - Single media by GUID +- `GET /item/{path}` - Single media by path +- `GET /items` - Multiple by IDs +- `GET /` - Query media + +**Security** (`/umbraco/delivery/api/v1/security/member`): +- `GET /authorize` - Start OAuth flow +- `POST /token` - Exchange code for token +- `GET /signout` - Revoke session + +--- + +## Quick Reference + +### Essential Commands + +```bash +# Build project +dotnet build src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj + +# Pack for NuGet +dotnet pack src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj -c Release + +# Run integration tests +dotnet test tests/Umbraco.Tests.Integration/ --filter "FullyQualifiedName~Delivery" + +# Check packages +dotnet list src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj package --outdated +``` + +### Key Classes + +| Class | Purpose | File | +|-------|---------|------| +| `DeliveryApiControllerBase` | Base controller with path decoding | Controllers/DeliveryApiControllerBase.cs | +| `ApiContentQueryService` | Query orchestration | Services/ApiContentQueryService.cs | +| `ApiAccessService` | Access control logic | Services/ApiAccessService.cs | +| `DeliveryApiOutputCachePolicy` | Cache policy implementation | Caching/DeliveryApiOutputCachePolicy.cs | +| `MemberController` | OAuth endpoints | Controllers/Security/MemberController.cs | +| `RequestContextOutputExpansionStrategyV2` | V2 output expansion | Rendering/RequestContextOutputExpansionStrategyV2.cs | + +### Important Files + +- `Umbraco.Cms.Api.Delivery.csproj` - Project dependencies +- `DependencyInjection/UmbracoBuilderExtensions.cs` - DI registration (lines 33-141) +- `Configuration/DeliveryApiConfiguration.cs` - API constants +- `Services/ApiContentQueryService.cs` - Query execution + +### Getting Help + +- **Root documentation**: `/CLAUDE.md` - Repository overview +- **API Common patterns**: `/src/Umbraco.Cms.Api.Common/CLAUDE.md` +- **Official docs**: https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api +- **Media docs**: https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api/media-delivery-api + +--- + +**This library exposes Umbraco content and media via REST for headless scenarios. Focus on query handlers, access control, and member authentication when working here.** diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs index 59227684623b..4ba3031a8578 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs @@ -47,9 +47,7 @@ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) return; } - swaggerDoc.Components ??= new OpenApiComponents(); - swaggerDoc.Components.SecuritySchemes ??= new Dictionary(); - swaggerDoc.Components.SecuritySchemes.Add( + swaggerDoc.AddComponent( AuthSchemeName, new OpenApiSecurityScheme { diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs index 24ba42b2d579..aeb99e2442a3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs @@ -1,7 +1,5 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.Content; -using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentEditing.Validation; using Umbraco.Cms.Core.PropertyEditors.Validation; @@ -12,7 +10,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content; public abstract class ContentControllerBase : ManagementApiControllerBase { - protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { @@ -98,6 +95,17 @@ protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperat .Build()), }); + protected IActionResult GetReferencesOperationStatusResult(GetReferencesOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + GetReferencesOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The requested content could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown get references operation status.") + .Build()), + }); + protected IActionResult ContentEditingOperationStatusResult( ContentEditingOperationStatus status, TContentModelBase requestModel, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs index 6a1d9b282416..a695895d1d98 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs @@ -4,8 +4,10 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Document.References; @@ -15,12 +17,33 @@ public class ReferencedByDocumentController : DocumentControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByDocumentController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByDocumentController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a paged list of tracked references for the current item, so you can see where an item is being used. /// @@ -31,20 +54,26 @@ public ReferencedByDocumentController(ITrackedReferencesService trackedReference [HttpGet("{id:guid}/referenced-by")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedBy( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Document, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs index 138b9196287b..39401533084c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Document.References; @@ -15,12 +17,32 @@ public class ReferencedDescendantsDocumentController : DocumentControllerBase private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - public ReferencedDescendantsDocumentController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsDocumentController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] + public async Task>> ReferencedDescendants( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = _umbracoMapper.MapEnumerable(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a paged list of the descendant nodes of the current item used in any kind of relation. /// @@ -32,19 +54,26 @@ public ReferencedDescendantsDocumentController(ITrackedReferencesService tracked [HttpGet("{id:guid}/referenced-descendants")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedDescendants( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Document, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = _umbracoMapper.MapEnumerable(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Install/SettingsInstallController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Install/SettingsInstallController.cs index 518edb9afe8f..466541f9e39e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Install/SettingsInstallController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Install/SettingsInstallController.cs @@ -1,6 +1,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Infrastructure.Install; using Umbraco.Cms.Api.Management.ViewModels.Installer; @@ -12,16 +13,23 @@ namespace Umbraco.Cms.Api.Management.Controllers.Install; [ApiVersion("1.0")] public class SettingsInstallController : InstallControllerBase { - private readonly InstallHelper _installHelper; private readonly IInstallSettingsFactory _installSettingsFactory; private readonly IUmbracoMapper _mapper; + [Obsolete("Please use the constructor without the InstallHelper parameter. Scheduled for removal in Umbraco 19.")] public SettingsInstallController( InstallHelper installHelper, IInstallSettingsFactory installSettingsFactory, IUmbracoMapper mapper) + : this(installSettingsFactory, mapper) + { + } + + [ActivatorUtilitiesConstructor] + public SettingsInstallController( + IInstallSettingsFactory installSettingsFactory, + IUmbracoMapper mapper) { - _installHelper = installHelper; _installSettingsFactory = installSettingsFactory; _mapper = mapper; } @@ -32,9 +40,6 @@ public SettingsInstallController( [ProducesResponseType(typeof(InstallSettingsResponseModel), StatusCodes.Status200OK)] public async Task Settings(CancellationToken cancellationToken) { - // Register that the install has started - await _installHelper.SetInstallStatusAsync(false, string.Empty); - InstallSettingsModel installSettings = _installSettingsFactory.GetInstallSettings(); InstallSettingsResponseModel responseModel = _mapper.Map(installSettings)!; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs index e9e62504edba..eaee4269cdd8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs @@ -4,8 +4,10 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Media.References; @@ -15,12 +17,33 @@ public class ReferencedByMediaController : MediaControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByMediaController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByMediaController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a page list of tracked references for the current item, so you can see where an item is being used. /// @@ -31,20 +54,26 @@ public ReferencedByMediaController(ITrackedReferencesService trackedReferencesSe [HttpGet("{id:guid}/referenced-by")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedBy( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Media, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs index c6c16cb222d5..c78b34a7ef7b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Media.References; @@ -14,13 +16,32 @@ public class ReferencedDescendantsMediaController : MediaControllerBase { private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - - public ReferencedDescendantsMediaController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsMediaController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] + public async Task>> ReferencedDescendants( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = _umbracoMapper.MapEnumerable(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a page list of the child nodes of the current item used in any kind of relation. /// @@ -32,19 +53,26 @@ public ReferencedDescendantsMediaController(ITrackedReferencesService trackedRef [HttpGet("{id:guid}/referenced-descendants")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedDescendants( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Media, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = _umbracoMapper.MapEnumerable(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs index 69237278a549..4e03fbfbf204 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs @@ -4,8 +4,10 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Member.References; @@ -15,12 +17,33 @@ public class ReferencedByMemberController : MemberControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByMemberController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByMemberController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] + public async Task>> ReferencedBy( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a page list of tracked references for the current item, so you can see where an item is being used. /// @@ -31,20 +54,26 @@ public ReferencedByMemberController(ITrackedReferencesService trackedReferencesS [HttpGet("{id:guid}/referenced-by")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedBy( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs index aa86950e6e62..8521ea380572 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Member.References; @@ -15,12 +17,32 @@ public class ReferencedDescendantsMemberController : MemberControllerBase private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - public ReferencedDescendantsMemberController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsMemberController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] + public async Task>> ReferencedDescendants( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = _umbracoMapper.MapEnumerable(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a page list of the child nodes of the current item used in any kind of relation. /// @@ -32,19 +54,26 @@ public ReferencedDescendantsMemberController(ITrackedReferencesService trackedRe [HttpGet("{id:guid}/referenced-descendants")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedDescendants( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Member, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = _umbracoMapper.MapEnumerable(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/CopyMemberTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/CopyMemberTypeController.cs index e88e50a1946c..02e1507f7627 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/CopyMemberTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/CopyMemberTypeController.cs @@ -20,6 +20,12 @@ public class CopyMemberTypeController : MemberTypeControllerBase public CopyMemberTypeController(IMemberTypeService memberTypeService) => _memberTypeService = memberTypeService; + [Obsolete("Please use the overload that includes all parameters. Scheduled for removal in Umbraco 19.")] + [NonAction] + public async Task Copy( + CancellationToken cancellationToken, + Guid id) => await Copy(cancellationToken, id, null); + [HttpPost("{id:guid}/copy")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status201Created)] diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Server/UpgradeCheckServerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Server/UpgradeCheckServerController.cs index 10a38944574a..a603532fe4d6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Server/UpgradeCheckServerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Server/UpgradeCheckServerController.cs @@ -13,6 +13,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Server; [ApiVersion("1.0")] [Authorize(Policy = AuthorizationPolicies.RequireAdminAccess)] +[Obsolete("Upgrade checks are no longer supported and this controller will be removed in Umbraco 19.")] public class UpgradeCheckServerController : ServerControllerBase { private readonly IUpgradeService _upgradeService; @@ -27,6 +28,7 @@ public UpgradeCheckServerController(IUpgradeService upgradeService, IUmbracoVers [HttpGet("upgrade-check")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(UpgradeCheckResponseModel), StatusCodes.Status200OK)] + [Obsolete("Upgrade checks are no longer supported and this endpoint will be removed in Umbraco 19.")] public async Task UpgradeCheck(CancellationToken cancellationToken) { UpgradeResult upgradeResult = await _upgradeService.CheckUpgrade(_umbracoVersion.SemanticVersion); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs index f7f291a66312..7d75b2df2b18 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs @@ -34,14 +34,14 @@ public StaticFileTreeControllerBase(IPhysicalFileSystem physicalFileSystem, IPhy protected override IFileSystem FileSystem { get; } - protected string[] GetDirectories(string path) => + protected override string[] GetDirectories(string path) => IsTreeRootPath(path) ? _allowedRootFolders : IsAllowedPath(path) ? _fileSystemTreeService.GetDirectories(path) : Array.Empty(); - protected string[] GetFiles(string path) + protected override string[] GetFiles(string path) => IsTreeRootPath(path) || IsAllowedPath(path) == false ? Array.Empty() : _fileSystemTreeService.GetFiles(path); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs index 81d764f69793..fa3bcdb71aae 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Umbraco.Cms.Api.Common.Accessors; using Umbraco.Cms.Api.Common.Rendering; using Umbraco.Cms.Api.Management.Factories; @@ -16,9 +16,10 @@ internal static IUmbracoBuilder AddWebhooks(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.AddMapDefinition(); - // deliveryApi will overwrite these more basic ones. - builder.Services.AddScoped(); - builder.Services.AddSingleton(); + // We have to use TryAdd here, as if they are registered by the delivery API, we don't want to register them + // Delivery API will also overwrite these IF it is enabled. + builder.Services.TryAddScoped(); + builder.Services.TryAddSingleton(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 4c495413c625..d6e7ab2d7479 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -9628,6 +9628,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -9692,6 +9706,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -17348,6 +17376,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -17412,6 +17454,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -22294,6 +22350,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -22358,6 +22428,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -27854,6 +27938,7 @@ "description": "The authenticated user does not have access to this resource" } }, + "deprecated": true, "security": [ { "Backoffice-User": [ ] @@ -50271,4 +50356,4 @@ "name": "Webhook" } ] -} \ No newline at end of file +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs index 894c837dd8a5..368684dea71e 100644 --- a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Extensions; +using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.IO; @@ -68,12 +68,12 @@ public FileSystemTreeItemPresentationModel[] GetSiblingsViewModels(string path, .ToArray(); } - public string[] GetDirectories(string path) => FileSystem + public virtual string[] GetDirectories(string path) => FileSystem .GetDirectories(path) .OrderBy(directory => directory) .ToArray(); - public string[] GetFiles(string path) => FileSystem + public virtual string[] GetFiles(string path) => FileSystem .GetFiles(path) .Where(FilterFile) .OrderBy(file => file) diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs index 3ee03e49276f..22943eb0e993 100644 --- a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs @@ -4,10 +4,31 @@ namespace Umbraco.Cms.Api.Management.Services.FileSystem; public class PhysicalFileSystemTreeService : FileSystemTreeServiceBase, IPhysicalFileSystemTreeService { + private static readonly string[] _allowedRootFolders = { $"{Path.DirectorySeparatorChar}App_Plugins", $"{Path.DirectorySeparatorChar}wwwroot" }; + private readonly IFileSystem _physicalFileSystem; protected override IFileSystem FileSystem => _physicalFileSystem; public PhysicalFileSystemTreeService(IPhysicalFileSystem physicalFileSystem) => _physicalFileSystem = physicalFileSystem; + + /// + public override string[] GetDirectories(string path) => + IsTreeRootPath(path) + ? _allowedRootFolders + : IsAllowedPath(path) + ? base.GetDirectories(path) + : Array.Empty(); + + /// + public override string[] GetFiles(string path) + => IsTreeRootPath(path) || IsAllowedPath(path) is false + ? [] + : base.GetFiles(path); + + private static bool IsTreeRootPath(string path) => path == Path.DirectorySeparatorChar.ToString(); + + private static bool IsAllowedPath(string path) => _allowedRootFolders.Contains(path) || _allowedRootFolders.Any(folder => path.StartsWith($"{folder}{Path.DirectorySeparatorChar}")); + } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Server/UpgradeCheckResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Server/UpgradeCheckResponseModel.cs index 0c84f0a8379f..edae04a0854a 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Server/UpgradeCheckResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Server/UpgradeCheckResponseModel.cs @@ -1,5 +1,6 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.Server; +namespace Umbraco.Cms.Api.Management.ViewModels.Server; +[Obsolete("Upgrade checks are no longer supported and this model will be removed in Umbraco 19.")] public class UpgradeCheckResponseModel { public required string Type { get; init; } diff --git a/src/Umbraco.Cms.DevelopmentMode.Backoffice/CLAUDE.md b/src/Umbraco.Cms.DevelopmentMode.Backoffice/CLAUDE.md new file mode 100644 index 000000000000..cb2da893b4ca --- /dev/null +++ b/src/Umbraco.Cms.DevelopmentMode.Backoffice/CLAUDE.md @@ -0,0 +1,258 @@ +# Umbraco.Cms.DevelopmentMode.Backoffice + +Development-time library enabling **InMemoryAuto** ModelsBuilder mode with runtime Razor view compilation. Allows content type changes to instantly regenerate strongly-typed models without application restart. + +--- + +## 1. Architecture + +**Type**: Class Library (NuGet Package) +**Target Framework**: .NET 10.0 +**Purpose**: Enable hot-reload of ModelsBuilder models during development + +### Key Technologies + +- **Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation** - Runtime Razor view compilation +- **Microsoft.CodeAnalysis.CSharp** (Roslyn) - Runtime C# compilation +- **AssemblyLoadContext** - Collectible assembly loading/unloading + +### Dependencies + +- `Umbraco.Web.Common` - Umbraco web infrastructure + +### Project Structure (18 source files) + +``` +Umbraco.Cms.DevelopmentMode.Backoffice/ +├── DependencyInjection/ +│ ├── BackofficeDevelopmentComposer.cs # Auto-registration via IComposer +│ └── UmbracoBuilderExtensions.cs # DI setup (CRITICAL: contains 80-line design overview) +└── InMemoryAuto/ + ├── InMemoryModelFactory.cs # Core factory - generates/loads models (876 lines) + ├── InMemoryAssemblyLoadContextManager.cs # Manages collectible AssemblyLoadContext + ├── RoslynCompiler.cs # Compiles generated C# to DLL + ├── CollectibleRuntimeViewCompiler.cs # Custom IViewCompiler for Razor (489 lines) + ├── UmbracoViewCompilerProvider.cs # Provides CollectibleRuntimeViewCompiler + ├── RuntimeCompilationCacheBuster.cs # Clears Razor caches on model rebuild + ├── UmbracoRazorReferenceManager.cs # Manages Roslyn MetadataReferences + ├── CompilationOptionsProvider.cs # Mirrors host app compilation settings + ├── UmbracoAssemblyLoadContext.cs # Collectible AssemblyLoadContext wrapper + ├── ChecksumValidator.cs # Validates precompiled view checksums + ├── CompilationExceptionFactory.cs # Creates detailed compilation errors + ├── UmbracoCompilationException.cs # Custom exception with CompilationFailures + ├── ModelsBuilderAssemblyAttribute.cs # Marks InMemory assemblies + ├── ModelsBuilderBindingErrorHandler.cs # Handles model binding version mismatches + ├── InMemoryModelsBuilderModeValidator.cs # Validates runtime mode configuration + └── ModelsModeConstants.cs # "InMemoryAuto" constant +``` + +### Design Patterns + +1. **Clone-and-Own Pattern** - ASP.NET Core's RuntimeViewCompiler and related classes are cloned because internal APIs can't be extended +2. **Collectible AssemblyLoadContext** - Enables assembly unloading for hot-reload +3. **FileSystemWatcher** - Monitors `~/umbraco/Data/TEMP/InMemoryAuto/` for changes +4. **Lazy Initialization** - References resolved on first use, not startup + +--- + +## 2. Key Patterns + +### Why Clone-and-Own (CRITICAL CONTEXT) + +The 80-line comment in `DependencyInjection/UmbracoBuilderExtensions.cs:13-80` explains why this project exists. Key points: + +1. **Problem**: ASP.NET Core's `RuntimeViewCompiler` loads assemblies into the default `AssemblyLoadContext`, which can't reference collectible contexts (breaking change in .NET 7) +2. **Failed Solutions**: + - Reflection to clear caches (unstable, internal APIs) + - Service wrapping via DI (still loads into wrong context) +3. **Solution**: Clone `RuntimeViewCompiler`, `RazorReferenceManager`, `ChecksumValidator`, and related classes, modifying them to: + - Load compiled views into the same collectible `AssemblyLoadContext` as models + - Explicitly add InMemoryAuto models assembly reference during compilation + +### InMemoryModelFactory Lifecycle + +``` +Content Type Changed → Reset() called → _pendingRebuild = true + ↓ +Next View Request → EnsureModels() → GetModelsAssembly(forceRebuild: true) + ↓ + GenerateModelsCode() → RoslynCompiler.CompileToFile() + ↓ + ReloadAssembly() → InMemoryAssemblyLoadContextManager.RenewAssemblyLoadContext() + ↓ + RuntimeCompilationCacheBuster.BustCache() → Views recompile with new models +``` + +### Assembly Caching Strategy + +Models are cached to disk for faster boot (`InMemoryModelFactory.cs:400-585`): + +1. **Hash Check**: `TypeModelHasher.Hash(typeModels)` creates hash of content type definitions +2. **Cache Files** (in `~/umbraco/Data/TEMP/InMemoryAuto/`): + - `models.hash` - Current content type hash + - `models.generated.cs` - Generated source + - `all.generated.cs` - Combined source with assembly attributes + - `all.dll.path` - Path to compiled DLL + - `Compiled/generated.cs{hash}.dll` - Compiled assembly + +3. **Boot Sequence**: + - Check if `models.hash` matches current hash + - If match, load existing DLL + - If mismatch or missing, recompile + +### Collectible AssemblyLoadContext + +```csharp +// InMemoryAssemblyLoadContextManager.cs:29-37 +internal void RenewAssemblyLoadContext() +{ + _currentAssemblyLoadContext?.Unload(); // Unload previous + _currentAssemblyLoadContext = new UmbracoAssemblyLoadContext(); // Create new collectible +} +``` + +**Key constraint**: No external references to the `AssemblyLoadContext` allowed - prevents unloading. + +--- + +## 3. Error Handling + +### Compilation Failures + +`CompilationExceptionFactory.cs` creates `UmbracoCompilationException` with: +- Source file content +- Generated code +- Diagnostic messages with line/column positions + +Errors logged in `CollectibleRuntimeViewCompiler.cs:383-396` and `InMemoryModelFactory.cs:341-354`. + +### Model Binding Errors + +`ModelsBuilderBindingErrorHandler.cs` handles version mismatches between: +- View's model type (from compiled view assembly) +- Content's model type (from InMemoryAuto assembly) + +Reports detailed error messages including assembly versions. + +--- + +## 4. Security + +**Runtime Mode Validation**: `InMemoryModelsBuilderModeValidator.cs` prevents `InMemoryAuto` mode outside `BackofficeDevelopment` runtime mode. + +**Temp File Location**: Models compiled to `~/umbraco/Data/TEMP/InMemoryAuto/` - ensure this directory isn't web-accessible. + +--- + +## 5. Edge Cases & Known Issues + +### Technical Debt (TODOs) + +1. **Circular Reference** - `InMemoryModelFactory.cs:46`: + ```csharp + private readonly Lazy _umbracoServices; // TODO: this is because of circular refs :( + ``` + +2. **DynamicMethod Performance** - `InMemoryModelFactory.cs:698-701`: + ```csharp + // TODO: use Core's ReflectionUtilities.EmitCtor !! + // Yes .. DynamicMethod is uber slow + ``` + +### Race Conditions + +`InMemoryModelFactory.cs:800-809` - FileSystemWatcher can cause race conditions on slow cloud filesystems. Own file changes are always ignored. + +### Unused Assembly Cleanup + +`InMemoryModelFactory.cs:587-612` - `TryDeleteUnusedAssemblies` may fail with `UnauthorizedAccessException` if files are locked. Cleanup retried on next rebuild. + +### Reflection for Cache Clearing + +`RuntimeCompilationCacheBuster.cs:50-51` uses reflection to call internal `RazorViewEngine.ClearCache()`: +```csharp +Action? clearCacheMethod = ReflectionUtilities.EmitMethod>("ClearCache"); +``` + +--- + +## 6. Configuration + +**Enable InMemoryAuto mode** (appsettings.json): +```json +{ + "Umbraco": { + "CMS": { + "Runtime": { + "Mode": "BackofficeDevelopment" + }, + "ModelsBuilder": { + "ModelsMode": "InMemoryAuto" + } + } + } +} +``` + +**Requirements**: +- `RuntimeMode` must be `BackofficeDevelopment` +- `ModelsMode` must be `InMemoryAuto` + +--- + +## Quick Reference + +### Essential Commands + +```bash +# Build +dotnet build src/Umbraco.Cms.DevelopmentMode.Backoffice/Umbraco.Cms.DevelopmentMode.Backoffice.csproj + +# Pack for NuGet +dotnet pack src/Umbraco.Cms.DevelopmentMode.Backoffice/Umbraco.Cms.DevelopmentMode.Backoffice.csproj -c Release + +# Run integration tests +dotnet test tests/Umbraco.Tests.Integration/ --filter "FullyQualifiedName~ModelsBuilder" +``` + +**Note**: This library has no direct tests - tested via integration tests in `Umbraco.Tests.Integration`. + +**Focus areas when modifying**: +- Assembly loading/unloading cycles +- Razor view compilation with models +- Cache invalidation timing +- Model binding error scenarios + +### Essential Classes + +| Class | Purpose | File | +|-------|---------|------| +| `InMemoryModelFactory` | Core model generation/loading | `InMemoryAuto/InMemoryModelFactory.cs` | +| `CollectibleRuntimeViewCompiler` | Custom Razor compiler | `InMemoryAuto/CollectibleRuntimeViewCompiler.cs` | +| `InMemoryAssemblyLoadContextManager` | Collectible assembly management | `InMemoryAuto/InMemoryAssemblyLoadContextManager.cs` | +| `RuntimeCompilationCacheBuster` | Cache invalidation | `InMemoryAuto/RuntimeCompilationCacheBuster.cs` | +| `UmbracoBuilderExtensions` | DI setup + design rationale | `DependencyInjection/UmbracoBuilderExtensions.cs` | + +### Important Files + +- **Design Overview**: `DependencyInjection/UmbracoBuilderExtensions.cs:13-80` - READ THIS FIRST +- **Project File**: `Umbraco.Cms.DevelopmentMode.Backoffice.csproj` +- **Model Factory**: `InMemoryAuto/InMemoryModelFactory.cs` - Most complex class + +### Cloned Microsoft Code + +These files are clones of ASP.NET Core internals - check upstream for updates: +- `ChecksumValidator.cs` - https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/ChecksumValidator.cs +- `UmbracoRazorReferenceManager.cs` - https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RazorReferenceManager.cs +- `CompilationOptionsProvider.cs` - Partial clone of CSharpCompiler +- `CompilationExceptionFactory.cs` - Partial clone of CompilationFailedExceptionFactory + +### Getting Help + +- **Root Documentation**: `/CLAUDE.md` +- **Core Patterns**: `/src/Umbraco.Core/CLAUDE.md` +- **Official Docs**: https://docs.umbraco.com/umbraco-cms/reference/configuration/modelsbuildersettings + +--- + +**This library enables hot-reload of content models during development. The core complexity is working around ASP.NET Core's internal Razor compilation APIs. Always read the design overview in `UmbracoBuilderExtensions.cs:13-80` before making changes.** diff --git a/src/Umbraco.Cms.Imaging.ImageSharp/CLAUDE.md b/src/Umbraco.Cms.Imaging.ImageSharp/CLAUDE.md new file mode 100644 index 000000000000..5ab8580907f2 --- /dev/null +++ b/src/Umbraco.Cms.Imaging.ImageSharp/CLAUDE.md @@ -0,0 +1,234 @@ +# Umbraco.Cms.Imaging.ImageSharp + +Image processing library using **ImageSharp 3.x** and **ImageSharp.Web** for on-the-fly image manipulation, resizing, cropping, and caching. + +--- + +## 1. Architecture + +**Type**: Class Library (NuGet Package) +**Target Framework**: .NET 10.0 +**Purpose**: Provide image manipulation via query string parameters + +### Key Technologies + +- **SixLabors.ImageSharp** - Image processing library +- **SixLabors.ImageSharp.Web** - ASP.NET Core middleware for query string-based image manipulation + +### Dependencies + +- `Umbraco.Web.Common` - Web infrastructure + +### Project Structure (7 source files) + +``` +Umbraco.Cms.Imaging.ImageSharp/ +├── ImageSharpComposer.cs # Auto-registration via IComposer +├── UmbracoBuilderExtensions.cs # DI setup and middleware configuration +├── ConfigureImageSharpMiddlewareOptions.cs # Middleware options (caching, HMAC, size limits) +├── ConfigurePhysicalFileSystemCacheOptions.cs # File cache location +├── ImageProcessors/ +│ └── CropWebProcessor.cs # Custom crop processor with EXIF awareness +└── Media/ + ├── ImageSharpDimensionExtractor.cs # Extract image dimensions (EXIF-aware) + └── ImageSharpImageUrlGenerator.cs # Generate query string URLs for processing +``` + +### Relationship to ImageSharp2 + +Two imaging packages exist: +- **Umbraco.Cms.Imaging.ImageSharp** (this package) - Uses ImageSharp 3.x (default) +- **Umbraco.Cms.Imaging.ImageSharp2** - Uses ImageSharp 2.x for backwards compatibility + +**Key difference**: ImageSharp 3.x WebP encoder defaults to Lossless (10x larger files), so this package explicitly sets `WebpFileFormatType.Lossy` at `ConfigureImageSharpMiddlewareOptions.cs:108-115`. + +--- + +## 2. Key Patterns + +### Query String Image Processing + +Images are processed via URL query parameters handled by ImageSharp.Web middleware: + +| Parameter | Purpose | Example | +|-----------|---------|---------| +| `width` / `height` | Resize dimensions | `?width=800&height=600` | +| `mode` | Crop mode (pad, crop, stretch, etc.) | `?mode=crop` | +| `anchor` | Crop anchor position | `?anchor=center` | +| `cc` | Crop coordinates (custom) | `?cc=0.1,0.1,0.1,0.1` | +| `rxy` | Focal point | `?rxy=0.5,0.3` | +| `format` | Output format | `?format=webp` | +| `quality` | Compression quality | `?quality=80` | + +### Pipeline Integration + +ImageSharp middleware runs **before** static files in `UmbracoBuilderExtensions.cs:44-50`: +```csharp +options.AddFilter(new UmbracoPipelineFilter(nameof(ImageSharpComposer)) +{ + PrePipeline = prePipeline => prePipeline.UseImageSharp() +}); +``` + +This ensures query strings are processed before serving static files. + +### EXIF Orientation Handling + +Both `ImageSharpDimensionExtractor` and `CropWebProcessor` account for EXIF rotation: + +```csharp +// ImageSharpDimensionExtractor.cs:42-44 - Swap width/height for rotated images +size = IsExifOrientationRotated(imageInfo) + ? new Size(imageInfo.Height, imageInfo.Width) + : new Size(imageInfo.Width, imageInfo.Height); +``` + +```csharp +// CropWebProcessor.cs:64-65 - Transform crop coordinates for EXIF orientation +Vector2 xy1 = ExifOrientationUtilities.Transform(new Vector2(left, top), Vector2.Zero, Vector2.One, orientation); +``` + +### HMAC Request Authorization + +When `HMACSecretKey` is configured, URLs are signed to prevent abuse (`ImageSharpImageUrlGenerator.cs:121-131`): +```csharp +if (_options.HMACSecretKey.Length != 0 && _requestAuthorizationUtilities is not null) +{ + var token = _requestAuthorizationUtilities.ComputeHMAC(uri, CommandHandling.Sanitize); + queryString.Add(RequestAuthorizationUtilities.TokenCommand, token); +} +``` + +--- + +## 3. Configuration + +### ImagingSettings (appsettings.json) + +```json +{ + "Umbraco": { + "CMS": { + "Imaging": { + "HMACSecretKey": "", + "Cache": { + "BrowserMaxAge": "7.00:00:00", + "CacheMaxAge": "365.00:00:00", + "CacheHashLength": 12, + "CacheFolder": "~/umbraco/Data/TEMP/MediaCache", + "CacheFolderDepth": 8 + }, + "Resize": { + "MaxWidth": 5000, + "MaxHeight": 5000 + } + } + } + } +} +``` + +### Security: Size Limits Without HMAC + +When HMAC is not configured, `ConfigureImageSharpMiddlewareOptions.cs:46-83` enforces max dimensions: +- Width/height requests exceeding `MaxWidth`/`MaxHeight` are **stripped** from the query +- This prevents DoS via excessive image generation + +When HMAC **is** configured, size validation is skipped (trusted requests). + +### Cache Busting + +Query parameters `rnd` or `v` trigger immutable cache headers (`ConfigureImageSharpMiddlewareOptions.cs:86-106`): +- Disables `MustRevalidate` +- Adds `immutable` directive + +--- + +## 4. Core Interfaces Implemented + +| Interface | Implementation | Purpose | +|-----------|----------------|---------| +| `IImageDimensionExtractor` | `ImageSharpDimensionExtractor` | Extract width/height from streams | +| `IImageUrlGenerator` | `ImageSharpImageUrlGenerator` | Generate manipulation URLs | +| `IImageWebProcessor` | `CropWebProcessor` | Custom crop with `cc` parameter | + +--- + +## 5. Edge Cases + +### WebP Encoding Change (ImageSharp 3.x) + +`ConfigureImageSharpMiddlewareOptions.cs:108-115` - ImageSharp 3.x defaults WebP to Lossless for PNGs, creating ~10x larger files. This is overridden: +```csharp +options.Configuration.ImageFormatsManager.SetEncoder( + WebpFormat.Instance, + new WebpEncoder { FileFormat = WebpFileFormatType.Lossy }); +``` + +### Crop Coordinates Format + +`CropWebProcessor.cs:50-56` - The `cc` parameter expects 4 values as distances from edges: +- Format: `left,top,right,bottom` (0-1 range, percentages) +- Right/bottom values are **distance from** those edges, not coordinates +- Zero values (`0,0,0,0`) are ignored (no crop) + +### Supported File Types + +Dynamically determined from ImageSharp configuration (`ImageSharpDimensionExtractor.cs:24`): +```csharp +SupportedImageFileTypes = configuration.ImageFormats.SelectMany(f => f.FileExtensions).ToArray(); +``` + +Default includes: jpg, jpeg, png, gif, bmp, webp, tiff, etc. + +--- + +## Quick Reference + +### Essential Commands + +```bash +# Build +dotnet build src/Umbraco.Cms.Imaging.ImageSharp/Umbraco.Cms.Imaging.ImageSharp.csproj + +# Run tests +dotnet test tests/Umbraco.Tests.UnitTests/ --filter "FullyQualifiedName~ImageSharp" +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `UmbracoBuilderExtensions.cs` | DI registration and pipeline setup | +| `ConfigureImageSharpMiddlewareOptions.cs` | Middleware config (caching, HMAC, size limits) | +| `CropWebProcessor.cs` | Custom `cc` crop parameter | +| `ImageSharpImageUrlGenerator.cs` | URL generation with HMAC signing | + +### URL Examples + +``` +# Basic resize +/media/image.jpg?width=800 + +# Crop to aspect ratio with focal point +/media/image.jpg?width=800&height=600&mode=crop&rxy=0.5,0.3 + +# Custom crop coordinates (10% from each edge) +/media/image.jpg?cc=0.1,0.1,0.1,0.1 + +# Format conversion with quality +/media/image.jpg?format=webp&quality=80 + +# Cache busted (immutable headers) +/media/image.jpg?width=800&v=abc123 +``` + +### Getting Help + +- **Root Documentation**: `/CLAUDE.md` +- **ImageSharp Docs**: https://docs.sixlabors.com/ +- **Umbraco Imaging**: https://docs.umbraco.com/umbraco-cms/reference/configuration/imagingsettings + +--- + +**This library provides query string-based image processing. Key concerns are EXIF orientation handling, WebP encoding defaults, and HMAC security for public-facing sites.** diff --git a/src/Umbraco.Cms.Imaging.ImageSharp2/CLAUDE.md b/src/Umbraco.Cms.Imaging.ImageSharp2/CLAUDE.md new file mode 100644 index 000000000000..b46b278e1610 --- /dev/null +++ b/src/Umbraco.Cms.Imaging.ImageSharp2/CLAUDE.md @@ -0,0 +1,139 @@ +# Umbraco.Cms.Imaging.ImageSharp2 + +Image processing library using **ImageSharp 2.x** for backwards compatibility with existing deployments. Use this package only when migrating from older Umbraco versions that depend on ImageSharp 2.x behavior. + +**Namespace Note**: Uses `Umbraco.Cms.Imaging.ImageSharp` (same as v3 package) for drop-in replacement - no code changes needed when switching. + +--- + +## 1. Architecture + +**Type**: Class Library (NuGet Package) +**Target Framework**: .NET 10.0 +**Purpose**: ImageSharp 2.x compatibility layer + +### Package Versions (Pinned) + +```xml + + + +``` + +Version constraint `[2.1.11, 3)` means: minimum 2.1.11, below 3.0. + +### Project Structure (7 source files) + +``` +Umbraco.Cms.Imaging.ImageSharp2/ +├── ImageSharpComposer.cs # Auto-registration via IComposer +├── UmbracoBuilderExtensions.cs # DI setup and middleware configuration +├── ConfigureImageSharpMiddlewareOptions.cs # Middleware options (caching, size limits) +├── ConfigurePhysicalFileSystemCacheOptions.cs # File cache location +├── ImageProcessors/ +│ └── CropWebProcessor.cs # Custom crop processor with EXIF awareness +└── Media/ + ├── ImageSharpDimensionExtractor.cs # Extract image dimensions (EXIF-aware) + └── ImageSharpImageUrlGenerator.cs # Generate query string URLs +``` + +--- + +## 2. Key Differences from ImageSharp (3.x) + +| Feature | ImageSharp2 (this) | ImageSharp (3.x) | +|---------|-------------------|------------------| +| **Package version** | 2.1.11 - 2.x | 3.x+ | +| **HMAC signing** | Not supported | Supported | +| **WebP default** | Lossy (native) | Lossless (overridden to Lossy) | +| **Cache buster param** | `rnd` only | `rnd` or `v` | +| **API differences** | `Image.Identify(config, stream)` | `Image.Identify(options, stream)` | +| **Size property** | `image.Image.Size()` method | `image.Image.Size` property | + +### API Differences in Code + +**ImageSharpDimensionExtractor** (`Media/ImageSharpDimensionExtractor.cs:31`): +```csharp +// v2: Direct method call +IImageInfo imageInfo = Image.Identify(_configuration, stream); + +// v3: Uses DecoderOptions +ImageInfo imageInfo = Image.Identify(options, stream); +``` + +**CropWebProcessor** (`ImageProcessors/CropWebProcessor.cs:67`): +```csharp +// v2: Size is a method +Size size = image.Image.Size(); + +// v3: Size is a property +Size size = image.Image.Size; +``` + +### Missing Features (vs ImageSharp 3.x) + +1. **No HMAC request authorization** - `HMACSecretKey` setting is ignored +2. **No `v` cache buster** - Only `rnd` parameter triggers immutable headers +3. **No WebP encoder override** - Uses default Lossy encoding (no configuration needed) + +--- + +## 3. When to Use This Package + +**Use ImageSharp2 when:** +- Migrating from Umbraco versions that used ImageSharp 2.x +- Third-party packages have hard dependency on ImageSharp 2.x +- Need exact byte-for-byte output compatibility with existing cached images + +**Use ImageSharp (3.x) when:** +- New installations +- Need HMAC URL signing for security +- Want latest performance improvements + +--- + +## 4. Configuration + +Same as ImageSharp 3.x. See `/src/Umbraco.Cms.Imaging.ImageSharp/CLAUDE.md` → Section 3 for full configuration details. + +**Key difference**: `HMACSecretKey` setting exists but is **ignored** in this package (no HMAC support in v2). + +--- + +## Quick Reference + +### Essential Commands + +```bash +# Build +dotnet build src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj + +# Run tests +dotnet test tests/Umbraco.Tests.UnitTests/ --filter "FullyQualifiedName~ImageSharp" +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `Umbraco.Cms.Imaging.ImageSharp2.csproj` | Version constraints (lines 7-8) | +| `ConfigureImageSharpMiddlewareOptions.cs` | Size limit enforcement | +| `Media/ImageSharpImageUrlGenerator.cs` | URL generation (no HMAC) | + +### Switching Between Packages + +To switch from ImageSharp2 to ImageSharp (3.x): +1. Remove `Umbraco.Cms.Imaging.ImageSharp2` package reference +2. Add `Umbraco.Cms.Imaging.ImageSharp` package reference +3. Clear media cache folder (`~/umbraco/Data/TEMP/MediaCache`) +4. No code changes needed (same namespace) + +### Getting Help + +- **ImageSharp 3.x Documentation**: `/src/Umbraco.Cms.Imaging.ImageSharp/CLAUDE.md` +- **Root Documentation**: `/CLAUDE.md` +- **SixLabors ImageSharp 2.x Docs**: https://docs.sixlabors.com/ + +--- + +**This is a backwards-compatibility package. For new projects, use `Umbraco.Cms.Imaging.ImageSharp` (3.x) instead.** diff --git a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/CLAUDE.md b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/CLAUDE.md new file mode 100644 index 000000000000..3708c3a4020e --- /dev/null +++ b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/CLAUDE.md @@ -0,0 +1,132 @@ +# Umbraco.Cms.Persistence.EFCore.SqlServer + +SQL Server-specific EF Core provider for Umbraco CMS. Contains SQL Server migrations and provider setup for the EF Core persistence layer. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Dependencies**: Umbraco.Cms.Persistence.EFCore + +--- + +## 1. Architecture + +### Project Purpose + +This is a thin provider project that implements SQL Server-specific functionality for the EF Core persistence layer: + +1. **Migration Provider** - Executes SQL Server-specific migrations +2. **Migration Provider Setup** - Configures DbContext to use SQL Server +3. **Migrations** - SQL Server-specific migration files for OpenIddict tables + +### Folder Structure + +``` +Umbraco.Cms.Persistence.EFCore.SqlServer/ +├── Migrations/ +│ ├── 20230622184303_InitialCreate.cs # No-op (NPoco creates tables) +│ ├── 20230807654321_AddOpenIddict.cs # OpenIddict tables +│ ├── 20240403140654_UpdateOpenIddictToV5.cs # OpenIddict v5 schema changes +│ ├── 20251006140751_UpdateOpenIddictToV7.cs # Token Type column expansion +│ └── UmbracoDbContextModelSnapshot.cs # Current model state +├── EFCoreSqlServerComposer.cs # DI registration +├── SqlServerMigrationProvider.cs # IMigrationProvider impl +└── SqlServerMigrationProviderSetup.cs # IMigrationProviderSetup impl +``` + +### Relationship with Parent Project + +This project extends `Umbraco.Cms.Persistence.EFCore`: + +- Implements `IMigrationProvider` interface defined in parent +- Implements `IMigrationProviderSetup` interface defined in parent +- Uses `UmbracoDbContext` from parent project +- Provider name: `Microsoft.Data.SqlClient` (from `Constants.ProviderNames.SQLServer`) + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). +**For EF Core migration commands**, see [parent project](../Umbraco.Cms.Persistence.EFCore/CLAUDE.md) (substitute `-p src/Umbraco.Cms.Persistence.EFCore.SqlServer`). + +--- + +## 3. Key Components + +### EFCoreSqlServerComposer (line 10-14) + +Registers SQL Server implementations of `IMigrationProvider` and `IMigrationProviderSetup`. + +### SqlServerMigrationProvider + +Executes migrations via `MigrateAsync(EFCoreMigration)` or `MigrateAllAsync()`. Unlike SQLite sibling, no transaction check needed (SQL Server handles concurrent migrations natively). + +### SqlServerMigrationProviderSetup (line 11-14) + +Configures `DbContextOptionsBuilder` with `UseSqlServer` and migrations assembly. + +--- + +## 4. Migrations + +### Migration History + +| Migration | Date | Purpose | +|-----------|------|---------| +| `InitialCreate` | 2023-06-22 | No-op - NPoco creates base tables | +| `AddOpenIddict` | 2023-08-07 | Creates OpenIddict tables | +| `UpdateOpenIddictToV5` | 2024-04-03 | Renames Type→ClientType, adds ApplicationType/JsonWebKeySet/Settings | +| `UpdateOpenIddictToV7` | 2025-10-06 | Expands Token.Type from nvarchar(50) to nvarchar(150) | + +### OpenIddict Tables Created + +Prefixed with `umbraco`: Applications, Authorizations, Scopes, Tokens (see SQLite sibling for details). + +### SQL Server-Specific Differences from SQLite + +1. **nvarchar types** - Uses `nvarchar(n)` and `nvarchar(max)` instead of TEXT +2. **v5 migration has actual changes** - Column rename and additions (SQLite handled differently) +3. **v7 migration has schema change** - Token.Type column expanded (SQLite didn't need this) + +### Adding New Migrations + +1. Configure SQL Server connection string in `src/Umbraco.Web.UI/appsettings.json` +2. Run migration command from repository root (see parent project docs) +3. **Critical**: Also add equivalent migration to `Umbraco.Cms.Persistence.EFCore.Sqlite` +4. Update `SqlServerMigrationProvider.GetMigrationType()` switch (line 27-35) if adding named migrations + +--- + +## 5. Project-Specific Notes + +### Named Migration Mapping + +`SqlServerMigrationProvider.GetMigrationType()` (line 27-35) maps `EFCoreMigration` enum to migration types. Update both parent enum and this switch when adding named migrations. + +### Transaction Handling + +SQL Server handles concurrent migrations natively - no transaction check needed (unlike SQLite). + +### Auto-Generated Files + +`UmbracoDbContextModelSnapshot.cs` is regenerated by EF Core - do not edit manually. + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `SqlServerMigrationProvider.cs` | Migration execution | +| `SqlServerMigrationProviderSetup.cs` | DbContext configuration | +| `EFCoreSqlServerComposer.cs` | DI registration | +| `Migrations/*.cs` | Migration files | + +### Provider Name +`Constants.ProviderNames.SQLServer` = `"Microsoft.Data.SqlClient"` + +### Related Projects +- **Parent**: `Umbraco.Cms.Persistence.EFCore` - Interfaces and base DbContext +- **Sibling**: `Umbraco.Cms.Persistence.EFCore.Sqlite` - SQLite equivalent diff --git a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/CLAUDE.md b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/CLAUDE.md new file mode 100644 index 000000000000..7d8936eba9e7 --- /dev/null +++ b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/CLAUDE.md @@ -0,0 +1,134 @@ +# Umbraco.Cms.Persistence.EFCore.Sqlite + +SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and provider setup for the EF Core persistence layer. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Dependencies**: Umbraco.Cms.Persistence.EFCore + +--- + +## 1. Architecture + +### Project Purpose + +This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer: + +1. **Migration Provider** - Executes SQLite-specific migrations +2. **Migration Provider Setup** - Configures DbContext to use SQLite +3. **Migrations** - SQLite-specific migration files for OpenIddict tables + +### Folder Structure + +``` +Umbraco.Cms.Persistence.EFCore.Sqlite/ +├── Migrations/ +│ ├── 20230622183638_InitialCreate.cs # No-op (NPoco creates tables) +│ ├── 20230807123456_AddOpenIddict.cs # OpenIddict tables +│ ├── 20240403141051_UpdateOpenIddictToV5.cs # OpenIddict v5 schema +│ ├── 20251006140958_UpdateOpenIddictToV7.cs # OpenIddict v7 (no-op for SQLite) +│ └── UmbracoDbContextModelSnapshot.cs # Current model state +├── EFCoreSqliteComposer.cs # DI registration +├── SqliteMigrationProvider.cs # IMigrationProvider impl +└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl +``` + +### Relationship with Parent Project + +This project extends `Umbraco.Cms.Persistence.EFCore`: + +- Implements `IMigrationProvider` interface defined in parent +- Implements `IMigrationProviderSetup` interface defined in parent +- Uses `UmbracoDbContext` from parent project +- Provider name: `Microsoft.Data.Sqlite` (from `Constants.ProviderNames.SQLLite`) + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). +**For EF Core migration commands**, see [parent project](../Umbraco.Cms.Persistence.EFCore/CLAUDE.md) (substitute `-p src/Umbraco.Cms.Persistence.EFCore.Sqlite`). + +--- + +## 3. Key Components + +### EFCoreSqliteComposer (line 10-14) + +Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite. + +### SqliteMigrationProvider + +- `MigrateAsync(EFCoreMigration)` - Runs specific named migration +- `MigrateAllAsync()` - Runs all pending migrations +- **Critical**: Cannot run `MigrateAllAsync` when transaction is active (line 26-29) + +### SqliteMigrationProviderSetup (line 11-14) + +Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly. + +--- + +## 4. Migrations + +### Migration History + +| Migration | Date | Purpose | +|-----------|------|---------| +| `InitialCreate` | 2023-06-22 | No-op - NPoco creates base tables | +| `AddOpenIddict` | 2023-08-07 | Creates OpenIddict tables (Applications, Tokens, Authorizations, Scopes) | +| `UpdateOpenIddictToV5` | 2024-04-03 | Schema updates for OpenIddict v5 | +| `UpdateOpenIddictToV7` | 2025-10-06 | No-op for SQLite (no schema changes needed) | + +### OpenIddict Tables Created + +All tables prefixed with `umbraco`: +- `umbracoOpenIddictApplications` - OAuth client applications +- `umbracoOpenIddictAuthorizations` - User authorizations +- `umbracoOpenIddictScopes` - OAuth scopes +- `umbracoOpenIddictTokens` - Access/refresh tokens + +### SQLite-Specific Notes + +- **TEXT column type** - SQLite uses TEXT for all strings (no varchar) +- **InitialCreate is no-op** - NPoco creates base tables, EF Core manages OpenIddict only +- **v7 migration is no-op** - SQLite didn't require schema changes that SQL Server needed + +### Adding New Migrations + +1. Configure SQLite connection string in `src/Umbraco.Web.UI/appsettings.json` +2. Run migration command from repository root (see parent project docs) +3. **Critical**: Also add equivalent migration to `Umbraco.Cms.Persistence.EFCore.SqlServer` +4. Update `SqliteMigrationProvider.GetMigrationType()` switch (line 34-42) if adding named migrations + +--- + +## 5. Project-Specific Notes + +### Named Migration Mapping + +`SqliteMigrationProvider.GetMigrationType()` (line 34-42) maps `EFCoreMigration` enum to migration types. When adding named migrations, update both the parent project's enum and this switch. + +### Auto-Generated Files + +`UmbracoDbContextModelSnapshot.cs` is regenerated by EF Core - do not edit manually. + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `SqliteMigrationProvider.cs` | Migration execution | +| `SqliteMigrationProviderSetup.cs` | DbContext configuration | +| `EFCoreSqliteComposer.cs` | DI registration | +| `Migrations/*.cs` | Migration files | + +### Provider Name +`Constants.ProviderNames.SQLLite` = `"Microsoft.Data.Sqlite"` + +### Related Projects +- **Parent**: `Umbraco.Cms.Persistence.EFCore` - Interfaces and base DbContext +- **Sibling**: `Umbraco.Cms.Persistence.EFCore.SqlServer` - SQL Server equivalent diff --git a/src/Umbraco.Cms.Persistence.EFCore/CLAUDE.md b/src/Umbraco.Cms.Persistence.EFCore/CLAUDE.md new file mode 100644 index 000000000000..844a46b3c071 --- /dev/null +++ b/src/Umbraco.Cms.Persistence.EFCore/CLAUDE.md @@ -0,0 +1,255 @@ +# Umbraco.Cms.Persistence.EFCore + +Entity Framework Core persistence layer for Umbraco CMS. This project provides EF Core integration including scoping, distributed locking, and migration infrastructure. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Dependencies**: Umbraco.Core, Umbraco.Infrastructure + +--- + +## 1. Architecture + +### Project Purpose + +This project bridges EF Core with Umbraco's existing persistence infrastructure. It provides: + +1. **UmbracoDbContext** - Base DbContext with automatic table prefix (`umbraco`) and provider-based configuration +2. **EF Core Scoping** - Integration with Umbraco's Unit of Work pattern (scopes) +3. **Distributed Locking** - SQL Server and SQLite locking mechanisms for EF Core contexts +4. **Migration Infrastructure** - Provider-agnostic migration execution + +### Folder Structure + +``` +Umbraco.Cms.Persistence.EFCore/ +├── Composition/ +│ └── UmbracoEFCoreComposer.cs # DI registration, OpenIddict setup +├── Extensions/ +│ ├── DbContextExtensions.cs # ExecuteScalarAsync, MigrateDatabaseAsync +│ └── UmbracoEFCoreServiceCollectionExtensions.cs # AddUmbracoDbContext +├── Locking/ +│ ├── SqlServerEFCoreDistributedLockingMechanism.cs +│ └── SqliteEFCoreDistributedLockingMechanism.cs +├── Migrations/ +│ ├── IMigrationProvider.cs # Provider-specific migration execution +│ └── IMigrationProviderSetup.cs # DbContext options setup per provider +├── Scoping/ +│ ├── AmbientEFCoreScopeStack.cs # AsyncLocal scope stack +│ ├── EFCoreScope.cs # Main scope implementation +│ ├── EFCoreDetachableScope.cs # Detachable scope for Deploy +│ ├── EFCoreScopeProvider.cs # Scope factory +│ ├── EFCoreScopeAccessor.cs # Ambient scope accessor +│ └── I*.cs # Interfaces +├── Constants-ProviderNames.cs # SQLite/SQLServer provider constants +├── EfCoreMigrationExecutor.cs # Migration orchestrator +├── StringExtensions.cs # Provider name comparison +└── UmbracoDbContext.cs # Base DbContext +``` + +### Key Design Decisions + +1. **Generic DbContext Support** - All scoping/locking is generic (``) allowing custom contexts +2. **Parallel NPoco Coexistence** - Designed to work alongside existing NPoco persistence layer +3. **Provider Abstraction** - `IMigrationProvider` and `IMigrationProviderSetup` enable database-agnostic migrations +4. **OpenIddict Integration** - UmbracoDbContext automatically registers OpenIddict entity sets + +### Database Providers + +Provider-specific implementations live in separate projects: +- `Umbraco.Cms.Persistence.EFCore.SqlServer` - SQL Server provider and migrations +- `Umbraco.Cms.Persistence.EFCore.Sqlite` - SQLite provider and migrations + +Provider names (from `Constants.ProviderNames`): +- `Microsoft.Data.SqlClient` - SQL Server +- `Microsoft.Data.Sqlite` - SQLite + +--- + +## 2. Commands + +**For Git workflow and standard build commands**, see [repository root](../../CLAUDE.md). + +### EF Core Migrations + +**Important**: Run from repository root with valid connection string in `src/Umbraco.Web.UI/appsettings.json`. + +```bash +# Add migration (SQL Server) +dotnet ef migrations add %MigrationName% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -c UmbracoDbContext + +# Add migration (SQLite) +dotnet ef migrations add %MigrationName% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -c UmbracoDbContext + +# Remove last migration (SQL Server) +dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer + +# Remove last migration (SQLite) +dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite + +# Generate migration script +dotnet ef migrations script -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer +``` + +--- + +## 3. Key Patterns + +### Adding a Custom DbContext + +Use `AddUmbracoDbContext` to register with full scope integration: + +```csharp +services.AddUmbracoDbContext((provider, options, connectionString, providerName) => +{ + options.UseOpenIddict(); // Optional +}); +``` + +Registers: `IDbContextFactory`, scope providers, accessors, and distributed locking + +### Using EF Core Scopes + +```csharp +using IEfCoreScope scope = _scopeProvider.CreateScope(); + +await scope.ExecuteWithContextAsync(async dbContext => +{ + return await dbContext.MyEntities.FirstOrDefaultAsync(x => x.Id == id); +}); + +scope.Complete(); // Required to commit +``` + +### UmbracoDbContext Table Naming + +All tables auto-prefixed with `umbraco` (see `UmbracoDbContext.cs:85-88`). + +--- + +## 4. Scoping System + +### Scope Hierarchy + +``` +EFCoreScopeProvider + ├── Creates EFCoreScope (normal scope) + └── Creates EFCoreDetachableScope (for Deploy scenarios) + +EFCoreScope + ├── Manages DbContext lifecycle + ├── Handles transactions (BeginTransaction/Commit/Rollback) + ├── Integrates with parent NPoco IScope when nested + └── Manages distributed locks via Locks property +``` + +### Ambient Scope Stack + +Uses `AsyncLocal>` for thread-safe tracking. Scopes must be disposed in LIFO order (child before parent). + +### Transaction Integration + +When an EF Core scope is created inside an NPoco scope: +1. The EF Core scope reuses the parent's `DbTransaction` +2. Transaction commit/rollback is delegated to the parent scope +3. DbContext connects to the same connection as the parent + +See `EFCoreScope.cs:158-180` for transaction initialization logic. + +--- + +## 5. Distributed Locking + +### SQL Server Locking + +Table-level locks with `REPEATABLEREAD` hint. Timeout via `SET LOCK_TIMEOUT`. Requires `ReadCommitted` or higher. + +### SQLite Locking + +Database-level locking (SQLite limitation). Read locks use snapshot isolation (WAL mode). Write locks are exclusive. Handles `SQLITE_BUSY/LOCKED` errors. + +### Lock Requirements + +Requires active scope, transaction, and minimum `ReadCommitted` isolation. + +--- + +## 6. Migration System + +### How Migrations Execute + +1. `UmbracoEFCoreComposer` registers `EfCoreMigrationExecutor` +2. Notification handler triggers on database creation +3. `EfCoreMigrationExecutor` finds correct `IMigrationProvider` by provider name +4. Provider executes via `dbContext.Database.Migrate()` + +### Adding New Migrations + +Create equivalent migrations in both SqlServer and Sqlite provider projects using commands from section 2. + +--- + +## 7. Project-Specific Notes + +### Known Technical Debt + +**Warning Suppressions** (`.csproj:9-14`): `IDE0270` (null checks), `CS0108` (hiding members), `CS1998` (async/await). + +**Detachable Scope** (`EFCoreDetachableScope.cs:93-94`): TODO for Deploy integration, limited test coverage. + +### Circular Dependency Handling + +`SqlServerEFCoreDistributedLockingMechanism` uses `Lazy>` to break circular dependency. + +### StaticServiceProvider Usage + +Fallback for design-time EF tooling (migrations) and startup edge cases when DI unavailable. + +### OpenIddict Integration + +`UmbracoEFCoreComposer` configures OpenIddict via `options.UseOpenIddict()` (see `Composition/UmbracoEFCoreComposer.cs:22-36`). + +### Vulnerable Dependency Override + +Top-level dependency on `Microsoft.Extensions.Caching.Memory` overrides vulnerable EF Core transitive dependency (`.csproj:18-19`). + +### InternalsVisibleTo + +`Umbraco.Tests.Integration` has access to internal types. + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `UmbracoDbContext.cs` | Base DbContext with table prefixing | +| `EFCoreScope.cs` | Main scope implementation | +| `EFCoreScopeProvider.cs` | Scope factory | +| `UmbracoEFCoreServiceCollectionExtensions.cs` | `AddUmbracoDbContext` extension | +| `UmbracoEFCoreComposer.cs` | DI registration | + +### Key Interfaces + +| Interface | Purpose | +|-----------|---------| +| `IEfCoreScope` | Scope contract with `ExecuteWithContextAsync` | +| `IEFCoreScopeProvider` | Creates/manages scopes | +| `IEFCoreScopeAccessor` | Access ambient scope | +| `IMigrationProvider` | Provider-specific migration execution | +| `IMigrationProviderSetup` | DbContext options setup per provider | + +### Provider Constants + +```csharp +Constants.ProviderNames.SQLServer = "Microsoft.Data.SqlClient" +Constants.ProviderNames.SQLLite = "Microsoft.Data.Sqlite" +``` + +### Related Projects + +- `Umbraco.Cms.Persistence.EFCore.SqlServer` - SQL Server implementation +- `Umbraco.Cms.Persistence.EFCore.Sqlite` - SQLite implementation +- `Umbraco.Infrastructure` - Contains NPoco scoping that this integrates with diff --git a/src/Umbraco.Cms.Persistence.SqlServer/CLAUDE.md b/src/Umbraco.Cms.Persistence.SqlServer/CLAUDE.md new file mode 100644 index 000000000000..4514899379ee --- /dev/null +++ b/src/Umbraco.Cms.Persistence.SqlServer/CLAUDE.md @@ -0,0 +1,235 @@ +# Umbraco.Cms.Persistence.SqlServer + +SQL Server database provider for Umbraco CMS using NPoco ORM. Provides SQL Server-specific SQL syntax, bulk copy operations, distributed locking, and Azure SQL transient fault handling. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Dependencies**: Umbraco.Infrastructure, NPoco.SqlServer, Microsoft.Data.SqlClient + +**Note**: This is the **legacy NPoco-based** SQL Server provider. For EF Core SQL Server support, see `Umbraco.Cms.Persistence.EFCore.SqlServer`. + +--- + +## 1. Architecture + +### Project Purpose + +This project provides complete SQL Server support for Umbraco's NPoco-based persistence layer: + +1. **SQL Syntax Provider** - SQL Server-specific SQL generation and schema operations +2. **Bulk Insert** - True `SqlBulkCopy` for high-performance batch inserts +3. **Distributed Locking** - Row-level locking with `REPEATABLEREAD` hints +4. **Fault Handling** - Azure SQL and network transient error retry policies +5. **LocalDB Support** - SQL Server LocalDB for development + +### Folder Structure + +``` +Umbraco.Cms.Persistence.SqlServer/ +├── Dtos/ +│ ├── ColumnInSchemaDto.cs # Schema query results +│ ├── ConstraintPerColumnDto.cs +│ ├── ConstraintPerTableDto.cs +│ ├── DefaultConstraintPerColumnDto.cs +│ └── DefinedIndexDto.cs +├── FaultHandling/ +│ ├── RetryPolicyFactory.cs # Creates retry policies +│ └── Strategies/ +│ ├── NetworkConnectivityErrorDetectionStrategy.cs +│ ├── SqlAzureTransientErrorDetectionStrategy.cs # Azure SQL error codes +│ └── ThrottlingCondition.cs # Azure throttling decode +├── Interceptors/ +│ ├── SqlServerAddMiniProfilerInterceptor.cs +│ ├── SqlServerAddRetryPolicyInterceptor.cs +│ └── SqlServerConnectionInterceptor.cs # Base interceptor +├── Services/ +│ ├── BulkDataReader.cs # Base bulk reader +│ ├── MicrosoftSqlSyntaxProviderBase.cs # Shared SQL syntax +│ ├── PocoDataDataReader.cs # NPoco bulk reader +│ ├── SqlServerBulkSqlInsertProvider.cs # SqlBulkCopy wrapper +│ ├── SqlServerDatabaseCreator.cs +│ ├── SqlServerDistributedLockingMechanism.cs +│ ├── SqlServerSpecificMapperFactory.cs +│ ├── SqlServerSyntaxProvider.cs # Main syntax provider +│ ├── SqlAzureDatabaseProviderMetadata.cs +│ ├── SqlLocalDbDatabaseProviderMetadata.cs +│ └── SqlServerDatabaseProviderMetadata.cs +├── Constants.cs # Provider name +├── LocalDb.cs # LocalDB management (~1,100 lines) +├── NPocoSqlServerDatabaseExtensions.cs # NPoco bulk extensions config +├── SqlServerComposer.cs # DI auto-registration +└── UmbracoBuilderExtensions.cs # AddUmbracoSqlServerSupport() +``` + +### Auto-Registration + +When this assembly is referenced, `SqlServerComposer` automatically registers all SQL Server services via `AddUmbracoSqlServerSupport()`. + +### Provider Name Migration + +Auto-migrates legacy `System.Data.SqlClient` to `Microsoft.Data.SqlClient`: + +**Line 53-59**: `UmbracoBuilderExtensions.cs` updates connection string provider name +```csharp +if (options.ProviderName == "System.Data.SqlClient") +{ + options.ProviderName = Constants.ProviderName; // Microsoft.Data.SqlClient +} +``` + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### SqlServerDistributedLockingMechanism (Services/SqlServerDistributedLockingMechanism.cs) + +Row-level locking using `REPEATABLEREAD` table hints: + +- **Read locks** (line 147): `SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id` +- **Write locks** (line 182-183): `UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=@id` +- **Timeout**: Uses `SET LOCK_TIMEOUT {milliseconds}` (lines 149, 185) +- **Error handling**: Catches SQL error 1222 (lock timeout) → throws `DistributedReadLockTimeoutException` or `DistributedWriteLockTimeoutException` +- **Minimum isolation**: Requires `ReadCommitted` or higher (lines 141-145, 176-180) + +### SqlServerBulkSqlInsertProvider (Services/SqlServerBulkSqlInsertProvider.cs) + +True bulk insert using `SqlBulkCopy`: + +```csharp +// Line 61-68: SqlBulkCopy configuration +new SqlBulkCopy(tConnection, SqlBulkCopyOptions.Default, tTransaction) +{ + BulkCopyTimeout = 0, // No timeout (uses connection timeout) + DestinationTableName = tableName, + BatchSize = 4096, // Consistent with NPoco +} +``` + +**Key features**: +- Streams data via `PocoDataDataReader` (line 69) - avoids in-memory DataTable +- Explicit column mappings by name (lines 74-77) - prevents column order mismatches +- Returns actual count inserted (line 80) - NPoco's `InsertBulk` doesn't provide this + +### SqlAzureTransientErrorDetectionStrategy (FaultHandling/Strategies/) + +Detects transient Azure SQL errors for retry: + +| Error Code | Description | +|------------|-------------| +| 40501 | Service busy (throttling) | +| 10928/10929 | Resource limits reached | +| 10053/10054 | Transport-level errors | +| 10060 | Connection timeout | +| 40197/40540/40143 | Service processing errors | +| 40613 | Database not available | +| 233 | Connection initialization error | +| 64 | Login process error | + +### SqlServerSyntaxProvider (Services/SqlServerSyntaxProvider.cs) + +SQL Server version detection and syntax generation: + +- **Engine editions**: Desktop, Standard, Enterprise, Express, Azure (lines 23-31) +- **SQL Server versions**: V7 through V2019, plus Azure (lines 33-47) +- **Default isolation**: `ReadCommitted` (line 69) +- **Azure detection**: `ServerVersion?.IsAzure` determines `DbProvider` (line 67) +- **Version detection**: Populated via `GetDbProviderManifest()` by querying SQL Server metadata + +--- + +## 4. LocalDB Support + +`LocalDb.cs` (~1,100 lines) provides comprehensive SQL Server LocalDB management: + +- Database creation/deletion +- Instance management +- File path handling +- Connection string generation + +**Known issues** (from TODO comments): +- Line 358: Stale database handling not implemented +- Line 359: File name assumptions may not always be correct + +--- + +## 5. Differences from SQLite Provider + +| Feature | SQL Server | SQLite | +|---------|-----------|--------| +| **Bulk Insert** | `SqlBulkCopy` (true bulk) | Transaction + individual inserts | +| **Locking** | Row-level with `REPEATABLEREAD` hints | Database-level (WAL mode) | +| **Types** | Native (NVARCHAR, DECIMAL, UNIQUEIDENTIFIER) | TEXT for most types | +| **Transient Retry** | Azure SQL error codes + network errors | BUSY/LOCKED retry only | +| **LocalDB** | Full support (~1,100 lines) | N/A | + +--- + +## 6. Project-Specific Notes + +### InternalsVisibleTo + +Test projects have access to internal types: +```xml +Umbraco.Tests.Integration +Umbraco.Tests.UnitTests +``` + +### Known Technical Debt + +1. **Warning Suppressions** (`.csproj:8-22`): Multiple analyzer warnings suppressed + - StyleCop: SA1405, SA1121, SA1117 + - IDE: 1006 (naming), 0270/0057/0054/0048 (simplification) + - CS0618 (obsolete usage), CS1574 (XML comments) + +2. **BulkInsert TODO** (`SqlServerBulkSqlInsertProvider.cs:44-46`): Custom `SqlBulkCopy` implementation used because NPoco's `InsertBulk` doesn't return record count. Performance comparison vs NPoco's DataTable approach pending. + +3. **LocalDB TODOs** (`LocalDb.cs:358-359`): Stale database cleanup and file name assumption handling incomplete. + +### NPoco Bulk Extensions + +`NPocoSqlServerDatabaseExtensions.ConfigureNPocoBulkExtensions()` is called during registration (line 50) to configure NPoco's SQL Server bulk operations. + +### Three Provider Metadata Types + +Different metadata for different SQL Server variants: +- `SqlServerDatabaseProviderMetadata` - Standard SQL Server +- `SqlLocalDbDatabaseProviderMetadata` - LocalDB +- `SqlAzureDatabaseProviderMetadata` - Azure SQL + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `SqlServerSyntaxProvider.cs` | Core SQL syntax provider | +| `SqlServerDistributedLockingMechanism.cs` | Row-level locking | +| `SqlServerBulkSqlInsertProvider.cs` | SqlBulkCopy wrapper | +| `SqlAzureTransientErrorDetectionStrategy.cs` | Azure retry logic | +| `LocalDb.cs` | LocalDB management | + +### Provider Name +`Constants.ProviderName` = `"Microsoft.Data.SqlClient"` + +### Registered Services + +All registered via `TryAddEnumerable`: +- `ISqlSyntaxProvider` → `SqlServerSyntaxProvider` +- `IBulkSqlInsertProvider` → `SqlServerBulkSqlInsertProvider` +- `IDatabaseCreator` → `SqlServerDatabaseCreator` +- `IProviderSpecificMapperFactory` → `SqlServerSpecificMapperFactory` +- `IDatabaseProviderMetadata` → Three variants (SqlServer, LocalDb, Azure) +- `IDistributedLockingMechanism` → `SqlServerDistributedLockingMechanism` +- `IProviderSpecificInterceptor` → MiniProfiler and RetryPolicy interceptors + +### Related Projects +- **Sibling**: `Umbraco.Cms.Persistence.Sqlite` - SQLite NPoco provider +- **EF Core**: `Umbraco.Cms.Persistence.EFCore.SqlServer` - EF Core SQL Server provider diff --git a/src/Umbraco.Cms.Persistence.Sqlite/CLAUDE.md b/src/Umbraco.Cms.Persistence.Sqlite/CLAUDE.md new file mode 100644 index 000000000000..5e15f919f3e9 --- /dev/null +++ b/src/Umbraco.Cms.Persistence.Sqlite/CLAUDE.md @@ -0,0 +1,204 @@ +# Umbraco.Cms.Persistence.Sqlite + +SQLite database provider for Umbraco CMS using NPoco ORM. Provides SQLite-specific SQL syntax, type mappers, distributed locking, and connection interceptors. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Dependencies**: Umbraco.Infrastructure, Microsoft.Data.Sqlite + +**Note**: This is the **legacy NPoco-based** SQLite provider. For EF Core SQLite support, see `Umbraco.Cms.Persistence.EFCore.Sqlite`. + +--- + +## 1. Architecture + +### Project Purpose + +This project provides complete SQLite support for Umbraco's NPoco-based persistence layer: + +1. **SQL Syntax Provider** - SQLite-specific SQL generation and schema operations +2. **Type Mappers** - GUID, decimal, date/time mapping for SQLite's type system +3. **Distributed Locking** - SQLite-specific locking using WAL mode +4. **Connection Interceptors** - Deferred transactions, MiniProfiler, retry policies +5. **Bulk Insert** - SQLite-optimized record insertion + +### Folder Structure + +``` +Umbraco.Cms.Persistence.Sqlite/ +├── Interceptors/ +│ ├── SqliteAddMiniProfilerInterceptor.cs # MiniProfiler integration +│ ├── SqliteAddPreferDeferredInterceptor.cs # Deferred transaction wrapper +│ ├── SqliteAddRetryPolicyInterceptor.cs # Transient error retry +│ └── SqliteConnectionInterceptor.cs # Base interceptor +├── Mappers/ +│ ├── SqliteGuidScalarMapper.cs # GUID as TEXT mapping +│ ├── SqlitePocoDateAndTimeOnlyMapper.cs # Date/Time as TEXT +│ ├── SqlitePocoDecimalMapper.cs # Decimal as TEXT (lossless) +│ └── SqlitePocoGuidMapper.cs # GUID for NPoco +├── Services/ +│ ├── SqliteBulkSqlInsertProvider.cs # Bulk insert via transactions +│ ├── SqliteDatabaseCreator.cs # Database initialization +│ ├── SqliteDatabaseProviderMetadata.cs # Provider info +│ ├── SqliteDistributedLockingMechanism.cs # WAL-based locking +│ ├── SqliteExceptionExtensions.cs # Error code helpers +│ ├── SqlitePreferDeferredTransactionsConnection.cs # Deferred tx wrapper +│ ├── SqliteSpecificMapperFactory.cs # Type mapper factory +│ ├── SqliteSyntaxProvider.cs # SQL syntax (481 lines) +│ └── SqliteTransientErrorDetectionStrategy.cs # BUSY/LOCKED detection +├── Constants.cs # Provider name constant +├── SqliteComposer.cs # DI auto-registration +└── UmbracoBuilderExtensions.cs # AddUmbracoSqliteSupport() +``` + +### Auto-Registration + +When this assembly is referenced, `SqliteComposer` automatically registers all SQLite services via the `AddUmbracoSqliteSupport()` extension method. + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### SqliteSyntaxProvider (Services/SqliteSyntaxProvider.cs) + +The core SQL syntax provider implementing `ISqlSyntaxProvider`. Key characteristics: + +- **All strings stored as TEXT** with `COLLATE NOCASE` (case-insensitive) +- **GUIDs stored as TEXT** (no native GUID support) +- **Decimals stored as TEXT** to avoid REAL precision loss (line 51) +- **Default isolation level**: `IsolationLevel.Serializable` (line 66-67) +- **No identity insert support** (line 73) +- **No clustered index support** (line 76) +- **LIMIT instead of TOP** for pagination (line 273-278) +- **AUTOINCREMENT required** to prevent magic ID issues with negative IDs (line 224-228) + +### SqliteDistributedLockingMechanism (Services/SqliteDistributedLockingMechanism.cs) + +Database-level locking using SQLite WAL mode: + +- **Read locks**: Snapshot isolation via WAL (mostly no-op, just validates transaction exists) +- **Write locks**: Only one writer at a time (entire database locked) +- **Timeout handling**: Uses `CommandTimeout` for busy-wait (line 163) +- **Error handling**: Catches `SQLITE_BUSY` and `SQLITE_LOCKED` errors + +### SqlitePreferDeferredTransactionsConnection (Services/SqlitePreferDeferredTransactionsConnection.cs) + +Wraps `SqliteConnection` to force deferred transactions: + +```csharp +// Line 33-34: The critical behavior +protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + => _inner.BeginTransaction(isolationLevel, true); // <-- The important bit +``` + +This prevents immediate write locks when beginning transactions, allowing multiple readers. + +### Connection Interceptors + +- **SqliteAddPreferDeferredInterceptor** - Wraps connections with deferred transaction support +- **SqliteAddMiniProfilerInterceptor** - Adds profiling for development +- **SqliteAddRetryPolicyInterceptor** - Retries on transient SQLite errors + +--- + +## 4. SQLite Type Mapping + +SQLite has limited types. Umbraco maps .NET types as follows: + +| .NET Type | SQLite Type | Notes | +|-----------|-------------|-------| +| `int`, `long`, `bool` | INTEGER | Native support | +| `string` | TEXT COLLATE NOCASE | Case-insensitive | +| `Guid` | TEXT | Stored as string representation | +| `DateTime`, `DateTimeOffset` | TEXT | ISO format string | +| `decimal` | TEXT | Prevents REAL precision loss | +| `double`, `float` | REAL | Native support | +| `byte[]` | BLOB | Native support | + +--- + +## 5. Project-Specific Notes + +### Database Creation Prevention + +`UmbracoBuilderExtensions.cs` (line 49-64) prevents accidental database file creation: + +```csharp +// Changes ReadWriteCreate mode to ReadWrite only +if (connectionStringBuilder.Mode == SqliteOpenMode.ReadWriteCreate) +{ + connectionStringBuilder.Mode = SqliteOpenMode.ReadWrite; +} +``` + +This ensures the database must exist before Umbraco connects. + +### WAL Mode Locking + +SQLite with WAL (Write-Ahead Logging) journal mode: +- Multiple readers can access snapshots concurrently +- Only one writer at a time (database-level lock) +- Write lock uses `umbracoLock` table with UPDATE to acquire + +### Bulk Insert Implementation + +`SqliteBulkSqlInsertProvider` (line 38-60) doesn't use true bulk copy (SQLite doesn't support it). Instead: +1. Wraps inserts in a transaction if not already in one +2. Inserts records one-by-one with `database.Insert()` +3. Completes transaction + +This is slower than SQL Server's `SqlBulkCopy` but safe for SQLite. + +### Known Technical Debt + +1. **Warning Suppression** (`.csproj:8-12`): `CS0114` - hiding inherited members needs fixing +2. **TODO in SqliteSyntaxProvider** (line 178): `TryGetDefaultConstraint` not implemented for SQLite + +### Differences from SQL Server Provider + +| Feature | SQLite | SQL Server | +|---------|--------|------------| +| Bulk Insert | Transaction + individual inserts | SqlBulkCopy | +| Locking | Database-level (WAL) | Row-level | +| Identity Insert | Not supported | Supported | +| Clustered Indexes | Not supported | Supported | +| TOP clause | LIMIT | TOP | +| Decimal | TEXT (lossless) | DECIMAL | +| GUID | TEXT | UNIQUEIDENTIFIER | + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `SqliteSyntaxProvider.cs` | Core SQL syntax provider | +| `SqliteDistributedLockingMechanism.cs` | Database locking | +| `UmbracoBuilderExtensions.cs` | DI registration | +| `SqlitePreferDeferredTransactionsConnection.cs` | Deferred tx support | + +### Provider Name +`Constants.ProviderName` = `"Microsoft.Data.Sqlite"` + +### Registered Services + +All registered via `TryAddEnumerable` (allowing multiple providers): +- `ISqlSyntaxProvider` → `SqliteSyntaxProvider` +- `IBulkSqlInsertProvider` → `SqliteBulkSqlInsertProvider` +- `IDatabaseCreator` → `SqliteDatabaseCreator` +- `IProviderSpecificMapperFactory` → `SqliteSpecificMapperFactory` +- `IDatabaseProviderMetadata` → `SqliteDatabaseProviderMetadata` +- `IDistributedLockingMechanism` → `SqliteDistributedLockingMechanism` +- `IProviderSpecificInterceptor` → Three interceptors + +### Related Projects +- **Sibling**: `Umbraco.Cms.Persistence.SqlServer` - SQL Server NPoco provider +- **EF Core**: `Umbraco.Cms.Persistence.EFCore.Sqlite` - EF Core SQLite provider diff --git a/src/Umbraco.Cms.StaticAssets/CLAUDE.md b/src/Umbraco.Cms.StaticAssets/CLAUDE.md new file mode 100644 index 000000000000..7ad044a42bf3 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/CLAUDE.md @@ -0,0 +1,260 @@ +# Umbraco.Cms.StaticAssets + +Static assets and Razor views for Umbraco CMS. This is a Razor Class Library (RCL) that packages all backoffice, login, and website assets for deployment. + +**Project Type**: Razor Class Library (NuGet package) +**Target Framework**: net10.0 +**SDK**: Microsoft.NET.Sdk.Razor + +--- + +## 1. Architecture + +### Project Purpose + +This project packages all static assets required for Umbraco CMS at runtime: + +1. **Razor Views** - Server-rendered pages for backoffice, login, and error pages +2. **Static Web Assets** - JavaScript, CSS, fonts, images served at runtime +3. **Build Integration** - MSBuild targets that compile TypeScript/frontend projects on demand + +### Key Characteristic + +**No C# Source Code** - This is a pure asset packaging project. All functionality comes from referenced projects (`Umbraco.Cms.Api.Management`, `Umbraco.Web.Website`). + +### Folder Structure + +``` +Umbraco.Cms.StaticAssets/ +├── umbraco/ # Razor Views (server-rendered) +│ ├── UmbracoBackOffice/ +│ │ └── Index.cshtml # Backoffice SPA shell (69 lines) +│ ├── UmbracoLogin/ +│ │ └── Index.cshtml # Login page (99 lines) +│ └── UmbracoWebsite/ +│ ├── NoNodes.cshtml # "No published content" page +│ ├── NotFound.cshtml # 404 error page +│ └── Maintenance.cshtml # Maintenance mode page +│ +├── wwwroot/ # Static Web Assets +│ ├── App_Plugins/ +│ │ └── Umbraco.BlockGridEditor.DefaultCustomViews/ # Block grid demo templates +│ └── umbraco/ +│ ├── assets/ # Logos and branding (see README.md) +│ ├── backoffice/ # Built backoffice SPA (from Umbraco.Web.UI.Client) +│ │ ├── apps/ # Application modules +│ │ ├── assets/ # Fonts, language files +│ │ ├── css/ # Themes and stylesheets +│ │ └── monaco-editor/ # Code editor assets +│ ├── login/ # Built login SPA (from Umbraco.Web.UI.Login) +│ └── website/ # Frontend website assets (fonts, CSS) +│ +└── Umbraco.Cms.StaticAssets.csproj # Build configuration (149 lines) +``` + +### Project Dependencies + +```xml + + +``` + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### Razor Views + +| View | Purpose | Line Count | +|------|---------|------------| +| `UmbracoBackOffice/Index.cshtml` | Backoffice SPA entry point with `` web component | 69 | +| `UmbracoLogin/Index.cshtml` | Login page with `` web component | 99 | +| `UmbracoWebsite/NoNodes.cshtml` | Welcome page when no content published | 59 | +| `UmbracoWebsite/NotFound.cshtml` | 404 error page (debug info in debug mode) | 79 | +| `UmbracoWebsite/Maintenance.cshtml` | Maintenance mode during upgrades | 65 | + +### Backoffice Index View (umbraco/UmbracoBackOffice/Index.cshtml) + +Key injected services: +- `IBackOfficePathGenerator` - Generates backoffice URL paths +- `IPackageManifestService` - Package manifest discovery +- `IJsonSerializer` - JSON serialization for import maps +- `IProfilerHtml` - MiniProfiler integration + +Key features: +- **Import Maps** (line 35): `Html.BackOfficeImportMapScriptAsync()` generates JavaScript module import maps +- **Debug Mode** (line 16, 63-66): `?umbDebug=true` query param enables profiler +- **NoScript Fallback** (lines 40-60): Displays message if JavaScript disabled + +### Login View (umbraco/UmbracoLogin/Index.cshtml) + +Configures `` web component with attributes: +- `return-url` - Redirect after login +- `logo-image` / `background-image` - Branding URLs from `BackOfficeGraphicsController` +- `username-is-email` - From `SecuritySettings` +- `allow-user-invite` / `allow-password-reset` - Email capability check +- `disable-local-login` - External login provider configuration + +--- + +## 4. Build System + +### Frontend Build Integration (csproj lines 36-90) + +The `.csproj` contains MSBuild targets that automatically build frontend projects when assets are missing. + +**Backoffice Build** (lines 36-90): +``` +BackofficeProjectDirectory = ../Umbraco.Web.UI.Client/ +BackofficeAssetsPath = wwwroot/umbraco/backoffice +``` + +**Login Build** (lines 94-148): +``` +LoginProjectDirectory = ../Umbraco.Web.UI.Login/ +LoginAssetsPath = wwwroot/umbraco/login +``` + +### Build Targets + +| Target | Purpose | +|--------|---------| +| `BuildStaticAssetsPreconditions` | Checks if build needed (Visual Studio only) | +| `RestoreBackoffice` | Runs `npm i` if package-lock changed | +| `BuildBackoffice` | Runs `npm run build:for:cms` | +| `DefineBackofficeAssets` | Registers assets with StaticWebAssets system | +| `CleanBackoffice` | Removes built assets on `dotnet clean` | + +### Build Conditions + +- **UmbracoBuild Variable**: When set (CI/CD), skips frontend builds (pre-built assets expected) +- **Visual Studio Detection**: `'$(UmbracoBuild)' == ''` indicates VS build +- **preserve.backoffice Marker**: Skip clean if `preserve.backoffice` file exists in solution root + +--- + +## 5. Static Web Assets + +### Asset Categories + +| Directory | Contents | Served At | +|-----------|----------|-----------| +| `wwwroot/umbraco/assets/` | Branding logos | Via `BackOfficeGraphicsController` API | +| `wwwroot/umbraco/backoffice/` | Built backoffice SPA | `/umbraco/backoffice/*` | +| `wwwroot/umbraco/login/` | Built login SPA | `/umbraco/login/*` | +| `wwwroot/umbraco/website/` | Website assets (fonts, CSS) | `/umbraco/website/*` | +| `wwwroot/App_Plugins/` | Block editor demo views | `/App_Plugins/*` | + +### Logo Assets (wwwroot/umbraco/assets/) + +Documented in `wwwroot/umbraco/assets/README.md` (16 lines): + +| File | Usage | API Endpoint | +|------|-------|--------------| +| `logo.svg` | Backoffice and public sites | `/umbraco/management/api/v1/security/back-office/graphics/logo` | +| `logo_dark.svg` | Login screen (dark mode) | `.../graphics/login-logo-alternative` | +| `logo_light.svg` | Login screen (light mode) | `.../graphics/login-logo` | +| `logo_blue.svg` | Alternative branding | N/A | + +### Block Grid Demo Views (wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/) + +Pre-built AngularJS templates for block grid editor demos: +- `umbBlockGridDemoHeadlineBlock.html` +- `umbBlockGridDemoImageBlock.html` +- `umbBlockGridDemoRichTextBlock.html` +- `umbBlockGridDemoTwoColumnLayoutBlock.html` + +--- + +## 6. Project-Specific Notes + +### Static Web Asset Base Path + +```xml +/ +``` + +Assets are served from root path, not under assembly name. + +### Compression Disabled + +```xml +false +``` + +Comment notes `MapStaticAssets()` is not used (yet). + +### Known Technical Debt + +1. **Warning Suppression** (`.csproj:14-16`): `NU5123` - File paths too long for NuGet package. TODO indicates files should be renamed. + +2. **Excluded Content** (`.csproj:31`): `wwwroot/umbraco/assets/README.md` explicitly excluded from package. + +### Backoffice Localization + +The backoffice includes language files for 25+ languages in `wwwroot/umbraco/backoffice/assets/lang/`: +- ar, bs, cs, cy, da, de, en, en-us, es, fr, he, hr, it, ja, ko, nb, nl, pl, pt, pt-br, ro, ru, sv, tr, uk, zh, zh-tw + +### Monaco Editor + +Full Monaco code editor included at `wwwroot/umbraco/backoffice/monaco-editor/` for rich code editing in backoffice. + +### Theming + +CSS themes in `wwwroot/umbraco/backoffice/css/`: +- `umb-css.css` - Main styles +- `uui-css.css` - UI library styles +- `dark.theme.css` - Dark theme +- `high-contrast.theme.css` - Accessibility theme +- `umbraco-blockgridlayout.css` - Block grid styles +- `rte-content.css` - Rich text editor content styles + +--- + +## Quick Reference + +### Essential Files + +| File | Purpose | +|------|---------| +| `umbraco/UmbracoBackOffice/Index.cshtml` | Backoffice entry point | +| `umbraco/UmbracoLogin/Index.cshtml` | Login page | +| `wwwroot/umbraco/assets/README.md` | Asset documentation | +| `Umbraco.Cms.StaticAssets.csproj` | Build targets for frontend | + +### Related Projects + +| Project | Relationship | +|---------|--------------| +| `Umbraco.Web.UI.Client` | Source for backoffice assets (npm build) | +| `Umbraco.Web.UI.Login` | Source for login assets (npm build) | +| `Umbraco.Cms.Api.Management` | Razor view dependencies | +| `Umbraco.Web.Website` | Razor view dependencies | + +### Build Commands (Manual) + +```bash +# Build backoffice assets (from Umbraco.Web.UI.Client) +cd src/Umbraco.Web.UI.Client +npm install +npm run build:for:cms + +# Build login assets (from Umbraco.Web.UI.Login) +cd src/Umbraco.Web.UI.Login +npm install +npm run build +``` + +### Preserve Assets During Clean + +Create marker file to prevent asset deletion: +```bash +touch preserve.backoffice # In solution root +touch preserve.login # In solution root +``` diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index 3cd59acc387e..0497d618a920 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -1,11 +1,3 @@  - - - CP0002 - M:Umbraco.Cms.Core.Configuration.Models.ContentSettings.get_Error404Collection - lib/net9.0/Umbraco.Core.dll - lib/net9.0/Umbraco.Core.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index de5e5d1f7a51..3ad4095a7cd0 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -17,6 +17,7 @@ public static class Web /// public const string AcceptPreviewCookieName = "UMB-WEBSITE-PREVIEW-ACCEPT"; + [Obsolete("InstallerCookieName is no longer used and will be removed in Umbraco 19.")] public const string InstallerCookieName = "umb_installId"; /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IInstallationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IInstallationRepository.cs index f12bd612fc67..1c1f65f01b7d 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IInstallationRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IInstallationRepository.cs @@ -1,6 +1,8 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; +[Obsolete("Installation logging is no longer supported and this interface will be removed in Umbraco 19.")] public interface IInstallationRepository { + [Obsolete("This method no longer has any function and will be removed in Umbraco 19.")] Task SaveInstallLogAsync(InstallLog installLog); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IUpgradeCheckRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IUpgradeCheckRepository.cs index 7a0d8b6f7460..a782717e8382 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IUpgradeCheckRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IUpgradeCheckRepository.cs @@ -2,7 +2,9 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; +[Obsolete("Upgrade checks are no longer supported and this interface will be removed in Umbraco 19.")] public interface IUpgradeCheckRepository { + [Obsolete("This method no longer has any function and will be removed in Umbraco 19.")] Task CheckUpgradeAsync(SemVersion version); } diff --git a/src/Umbraco.Core/Persistence/Repositories/InstallationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/InstallationRepository.cs index e79edec7f712..991af0001ee8 100644 --- a/src/Umbraco.Core/Persistence/Repositories/InstallationRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/InstallationRepository.cs @@ -3,31 +3,15 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; +[Obsolete("Installation logging is no longer supported and this class will be removed in Umbraco 19.")] public class InstallationRepository : IInstallationRepository { - private const string RestApiInstallUrl = "https://our.umbraco.com/umbraco/api/Installation/Install"; - private static HttpClient? _httpClient; - private readonly IJsonSerializer _jsonSerializer; - public InstallationRepository(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer; - - public async Task SaveInstallLogAsync(InstallLog installLog) + public InstallationRepository(IJsonSerializer jsonSerializer) { - try - { - if (_httpClient == null) - { - _httpClient = new HttpClient(); - } - - using var content = new StringContent(_jsonSerializer.Serialize(installLog), Encoding.UTF8, "application/json"); - await _httpClient.PostAsync(RestApiInstallUrl, content); - } - - // this occurs if the server for Our is down or cannot be reached - catch (HttpRequestException) - { - } } + + [Obsolete("This method no longer has any function and will be removed in Umbraco 19.")] + public Task SaveInstallLogAsync(InstallLog installLog) => Task.CompletedTask; } diff --git a/src/Umbraco.Core/Persistence/Repositories/UpgradeCheckRepository.cs b/src/Umbraco.Core/Persistence/Repositories/UpgradeCheckRepository.cs index 3dab741accd4..ddd9c0cf8204 100644 --- a/src/Umbraco.Core/Persistence/Repositories/UpgradeCheckRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/UpgradeCheckRepository.cs @@ -1,54 +1,16 @@ -using System.Text; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Core.Persistence.Repositories; +[Obsolete("Upgrade checks are no longer supported and this repository will be removed in Umbraco 19.")] public class UpgradeCheckRepository : IUpgradeCheckRepository { - private const string RestApiUpgradeChecklUrl = "https://our.umbraco.com/umbraco/api/UpgradeCheck/CheckUpgrade"; - private static HttpClient? _httpClient; - private readonly IJsonSerializer _jsonSerializer; - - public UpgradeCheckRepository(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer; - - public async Task CheckUpgradeAsync(SemVersion version) + public UpgradeCheckRepository(IJsonSerializer jsonSerializer) { - try - { - _httpClient ??= new HttpClient { Timeout = TimeSpan.FromSeconds(1) }; - - using var content = new StringContent(_jsonSerializer.Serialize(new CheckUpgradeDto(version)), Encoding.UTF8, "application/json"); - using HttpResponseMessage task = await _httpClient.PostAsync(RestApiUpgradeChecklUrl, content); - var json = await task.Content.ReadAsStringAsync(); - UpgradeResult? result = _jsonSerializer.Deserialize(json); - - return result ?? new UpgradeResult("None", string.Empty, string.Empty); - } - catch (HttpRequestException) - { - // this occurs if the server for Our is down or cannot be reached - return new UpgradeResult("None", string.Empty, string.Empty); - } } - private sealed class CheckUpgradeDto - { - public CheckUpgradeDto(SemVersion version) - { - VersionMajor = version.Major; - VersionMinor = version.Minor; - VersionPatch = version.Patch; - VersionComment = version.Prerelease; - } - - public int VersionMajor { get; } - - public int VersionMinor { get; } - - public int VersionPatch { get; } - - public string VersionComment { get; } - } + [Obsolete("This method no longer has any function and will be removed in Umbraco 19.")] + public Task CheckUpgradeAsync(SemVersion version) => Task.FromResult(new UpgradeResult("None", string.Empty, string.Empty)); } diff --git a/src/Umbraco.Core/PropertyEditors/BlockGridConfiguration.cs b/src/Umbraco.Core/PropertyEditors/BlockGridConfiguration.cs index ade1da8b8af2..859515822fc8 100644 --- a/src/Umbraco.Core/PropertyEditors/BlockGridConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/BlockGridConfiguration.cs @@ -17,19 +17,39 @@ public class BlockGridConfiguration [ConfigurationField("gridColumns")] public int? GridColumns { get; set; } - public class BlockGridBlockConfiguration : IBlockConfiguration + public class BlockGridBlockConfiguration : IBlockGridConfiguration { - public int? AreaGridColumns { get; set; } - public BlockGridAreaConfiguration[] Areas { get; set; } = Array.Empty(); public Guid ContentElementTypeKey { get; set; } public Guid? SettingsElementTypeKey { get; set; } - public bool AllowAtRoot { get; set; } + public string? Label { get; set; } + + public string? EditorSize { get; set; } + + public string? IconColor { get; set; } + + public string? BackgroundColor { get; set; } + + public string? Thumbnail { get; set; } + + public bool? ForceHideContentEditorInOverlay { get; set; } + + public bool? DisplayInline { get; set; } + + public bool? AllowAtRoot { get; set; } + + public bool? AllowInAreas { get; set; } + + public bool? HideContentEditor { get; set; } + + public int? RowMinSpan { get; set; } - public bool AllowInAreas { get; set; } + public int? RowMaxSpan { get; set; } + + public int? AreaGridColumns { get; set; } } public class NumberRange @@ -52,5 +72,18 @@ public class BlockGridAreaConfiguration public int? MinAllowed { get; set; } public int? MaxAllowed { get; set; } + + public SpecifiedAllowanceConfiguration[] SpecifiedAllowance { get; set; } = Array.Empty(); + + public string? CreateLabel { get; set; } + } + + public class SpecifiedAllowanceConfiguration + { + public int? MinAllowed { get; set; } + + public int? MaxAllowed { get; set; } + + public Guid? ElementTypeKey { get; set; } } } diff --git a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs index 0e4c5fe00f0b..b920de48b17a 100644 --- a/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/BlockListConfiguration.cs @@ -23,6 +23,18 @@ public class BlockConfiguration : IBlockConfiguration public Guid ContentElementTypeKey { get; set; } public Guid? SettingsElementTypeKey { get; set; } + + public string? Label { get; set; } + + public string? EditorSize { get; set; } + + public string? IconColor { get; set; } + + public string? BackgroundColor { get; set; } + + public string? Thumbnail { get; set; } + + public bool? ForceHideContentEditorInOverlay { get; set; } } public class NumberRange diff --git a/src/Umbraco.Core/PropertyEditors/IBlockConfiguration.cs b/src/Umbraco.Core/PropertyEditors/IBlockConfiguration.cs index 350f4a084301..25691fcf8254 100644 --- a/src/Umbraco.Core/PropertyEditors/IBlockConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/IBlockConfiguration.cs @@ -8,4 +8,16 @@ public interface IBlockConfiguration public Guid ContentElementTypeKey { get; set; } public Guid? SettingsElementTypeKey { get; set; } + + public string? Label { get; set; } + + public string? EditorSize { get; set; } + + public string? IconColor { get; set; } + + public string? BackgroundColor { get; set; } + + public string? Thumbnail { get; set; } + + public bool? ForceHideContentEditorInOverlay { get; set; } } diff --git a/src/Umbraco.Core/PropertyEditors/IBlockGridConfiguration.cs b/src/Umbraco.Core/PropertyEditors/IBlockGridConfiguration.cs new file mode 100644 index 000000000000..208536841c6f --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IBlockGridConfiguration.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface IBlockGridConfiguration : IBlockConfiguration +{ + public bool? AllowAtRoot { get; set; } + + public bool? AllowInAreas { get; set; } + + public bool? HideContentEditor { get; set; } + + public int? RowMinSpan { get; set; } + + public int? RowMaxSpan { get; set; } + + public int? AreaGridColumns { get; set; } +} diff --git a/src/Umbraco.Core/PropertyEditors/IRichTextBlockConfiguration.cs b/src/Umbraco.Core/PropertyEditors/IRichTextBlockConfiguration.cs new file mode 100644 index 000000000000..be2785e49e5e --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IRichTextBlockConfiguration.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +public interface IRichTextBlockConfiguration : IBlockConfiguration +{ + public bool? DisplayInline { get; set; } +} diff --git a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs index 5ebcb13b5de1..4f90d2a12577 100644 --- a/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/RichTextConfiguration.cs @@ -14,10 +14,24 @@ public class RichTextConfiguration : IIgnoreUserStartNodesConfig [ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)] public bool IgnoreUserStartNodes { get; set; } - public class RichTextBlockConfiguration : IBlockConfiguration + public class RichTextBlockConfiguration : IRichTextBlockConfiguration { public Guid ContentElementTypeKey { get; set; } public Guid? SettingsElementTypeKey { get; set; } + + public string? Label { get; set; } + + public string? EditorSize { get; set; } + + public string? IconColor { get; set; } + + public string? BackgroundColor { get; set; } + + public string? Thumbnail { get; set; } + + public bool? ForceHideContentEditorInOverlay { get; set; } + + public bool? DisplayInline { get; set; } } } diff --git a/src/Umbraco.Core/Services/EntityXmlSerializer.cs b/src/Umbraco.Core/Services/EntityXmlSerializer.cs index 7c1c37475477..bf8bda086927 100644 --- a/src/Umbraco.Core/Services/EntityXmlSerializer.cs +++ b/src/Umbraco.Core/Services/EntityXmlSerializer.cs @@ -214,7 +214,7 @@ public XElement Serialize(IDataType dataType) xml.Add(new XAttribute("EditorUiAlias", dataType.EditorUiAlias ?? dataType.EditorAlias)); xml.Add(new XAttribute("Definition", dataType.Key)); xml.Add(new XAttribute("DatabaseType", dataType.DatabaseType.ToString())); - xml.Add(new XAttribute("Configuration", _configurationEditorJsonSerializer.Serialize(dataType.ConfigurationObject))); + xml.Add(new XAttribute("Configuration", SerializeDataTypeConfiguration(dataType))); var folderNames = string.Empty; var folderKeys = string.Empty; @@ -708,4 +708,13 @@ private void SerializeChildren(IEnumerable children, XElement xml, Actio } } } + + private string SerializeDataTypeConfiguration(IDataType dataType) => + + // We have two properties containing configuration data: + // 1. ConfigurationData - a dictionary that contains all the configuration data stored as key/value pairs. + // 2. ConfigurationObject - a strongly typed object that represents the configuration data known to the server. + // To fully be able to restore the package, we need to serialize the full ConfigurationData dictionary, not + // just the configuration properties known to the server. + _configurationEditorJsonSerializer.Serialize(dataType.ConfigurationData); } diff --git a/src/Umbraco.Core/Services/ITrackedReferencesService.cs b/src/Umbraco.Core/Services/ITrackedReferencesService.cs index cc97954846ad..ad84c79fcc83 100644 --- a/src/Umbraco.Core/Services/ITrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/ITrackedReferencesService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -16,8 +17,27 @@ public interface ITrackedReferencesService /// dependencies (isDependency field is set to true). /// /// A paged result of objects. + [Obsolete("Use GetPagedRelationsForItemAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of items which are in relation with the current item. + /// Basically, shows the items which depend on the current item. + /// + /// The identifier of the entity to retrieve relations for. + /// The Umbraco object type of the parent. + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// A paged result of objects. + async Task, GetReferencesOperationStatus>> GetPagedRelationsForItemAsync(Guid key, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) +#pragma warning disable CS0618 // Type or member is obsolete + => Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, await GetPagedRelationsForItemAsync(key, skip, take, filterMustBeIsDependency)); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Gets a paged result of items which are in relation with an item in the recycle bin. /// @@ -42,8 +62,26 @@ public interface ITrackedReferencesService /// dependencies (isDependency field is set to true). /// /// A paged result of objects. + [Obsolete("Use GetPagedDescendantsInReferencesAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of the descending items that have any references, given a parent id. + /// + /// The unique identifier of the parent to retrieve descendants for. + /// The Umbraco object type of the parent. + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// An wrapping a paged result of objects. + async Task, GetReferencesOperationStatus>> GetPagedDescendantsInReferencesAsync(Guid parentKey, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) +#pragma warning disable CS0618 // Type or member is obsolete + => Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, await GetPagedDescendantsInReferencesAsync(parentKey, skip, take, filterMustBeIsDependency)); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Gets a paged result of items used in any kind of relation from selected integer ids. /// diff --git a/src/Umbraco.Core/Services/IUpgradeService.cs b/src/Umbraco.Core/Services/IUpgradeService.cs index 2f1e65f00aa2..86cefd43f8ef 100644 --- a/src/Umbraco.Core/Services/IUpgradeService.cs +++ b/src/Umbraco.Core/Services/IUpgradeService.cs @@ -2,7 +2,9 @@ namespace Umbraco.Cms.Core.Services; +[Obsolete("Upgrade checks are no longer supported and this service will be removed in Umbraco 19.")] public interface IUpgradeService { + [Obsolete("This method no longer has any function and will be removed in Umbraco 19.")] Task CheckUpgrade(SemVersion version); } diff --git a/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs new file mode 100644 index 000000000000..2ba0e80dc8e0 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum GetReferencesOperationStatus +{ + Success, + ContentNotFound +} diff --git a/src/Umbraco.Core/Services/PreviewService.cs b/src/Umbraco.Core/Services/PreviewService.cs index 9810d8f07e19..43e24f0e07f7 100644 --- a/src/Umbraco.Core/Services/PreviewService.cs +++ b/src/Umbraco.Core/Services/PreviewService.cs @@ -34,7 +34,10 @@ public async Task 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"); } return attempt.Success; diff --git a/src/Umbraco.Core/Services/TrackedReferencesService.cs b/src/Umbraco.Core/Services/TrackedReferencesService.cs index 6babae6e054c..24332dcfa52c 100644 --- a/src/Umbraco.Core/Services/TrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/TrackedReferencesService.cs @@ -1,6 +1,8 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -20,6 +22,7 @@ public TrackedReferencesService( _entityService = entityService; } + [Obsolete("Use the GetPagedRelationsForItemAsync overload which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] public Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); @@ -29,6 +32,21 @@ public Task> GetPagedRelationsForItemAsync(Guid ke return Task.FromResult(pagedModel); } + public async Task, GetReferencesOperationStatus>> GetPagedRelationsForItemAsync(Guid key, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + IEntitySlim? entity = _entityService.Get(key, objectType); + if (entity is null) + { + return Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + } + +#pragma warning disable CS0618 // Type or member is obsolete (but using whilst it exists to avoid code repetition) + PagedModel pagedModel = await GetPagedRelationsForItemAsync(key, skip, take, filterMustBeIsDependency); +#pragma warning restore CS0618 // Type or member is obsolete + + return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, pagedModel); + } + public Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) { Guid objectTypeKey = objectType switch @@ -44,6 +62,7 @@ public Task> GetPagedRelationsForRecycleBinAsync(U return Task.FromResult(pagedModel); } + [Obsolete("Use GetPagedDescendantsInReferencesAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] public Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); @@ -59,6 +78,21 @@ public Task> GetPagedDescendantsInReferencesAsync( return Task.FromResult(pagedModel); } + public async Task, GetReferencesOperationStatus>> GetPagedDescendantsInReferencesAsync(Guid parentKey, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + IEntitySlim? entity = _entityService.Get(parentKey, objectType); + if (entity is null) + { + return Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + } + +#pragma warning disable CS0618 // Type or member is obsolete (but using whilst it exists to avoid code repetition) + PagedModel pagedModel = await GetPagedDescendantsInReferencesAsync(parentKey, skip, take, filterMustBeIsDependency); +#pragma warning restore CS0618 // Type or member is obsolete + + return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, pagedModel); + } + public Task> GetPagedItemsWithRelationsAsync(ISet keys, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); diff --git a/src/Umbraco.Core/Services/UpgradeService.cs b/src/Umbraco.Core/Services/UpgradeService.cs index 7a5269d2bf67..0c9a31d6a6b0 100644 --- a/src/Umbraco.Core/Services/UpgradeService.cs +++ b/src/Umbraco.Core/Services/UpgradeService.cs @@ -3,6 +3,7 @@ namespace Umbraco.Cms.Core.Services; +[Obsolete("Upgrade checks are no longer supported and this service will be removed in Umbraco 19.")] public class UpgradeService : IUpgradeService { private readonly IUpgradeCheckRepository _upgradeCheckRepository; diff --git a/src/Umbraco.Examine.Lucene/CLAUDE.md b/src/Umbraco.Examine.Lucene/CLAUDE.md new file mode 100644 index 000000000000..a19053a59e03 --- /dev/null +++ b/src/Umbraco.Examine.Lucene/CLAUDE.md @@ -0,0 +1,293 @@ +# Umbraco.Examine.Lucene + +Full-text search provider for Umbraco CMS using Examine and Lucene.NET. Provides content, media, member, and Delivery API indexing with configurable directory factories and index diagnostics. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Package ID**: Umbraco.Cms.Examine.Lucene +**Namespace**: Umbraco.Cms.Infrastructure.Examine +**Dependencies**: Umbraco.Infrastructure, Examine (Lucene.NET wrapper) + +--- + +## 1. Architecture + +### Project Purpose + +This project provides Lucene-based full-text search capabilities for Umbraco: + +1. **Index Types** - Content, Media, Member, and Delivery API indexes +2. **Directory Factories** - Configurable index storage (filesystem, temp, synced) +3. **Index Diagnostics** - Health checks and metadata for backoffice +4. **Backoffice Search** - Unified search across content tree with permissions + +### Folder Structure + +``` +Umbraco.Examine.Lucene/ +├── DependencyInjection/ +│ ├── ConfigureIndexOptions.cs # Per-index configuration (77 lines) +│ └── UmbracoBuilderExtensions.cs # AddExamineIndexes() registration (64 lines) +├── Extensions/ +│ └── ExamineExtensions.cs # Lucene query parsing, health check +├── AddExamineComposer.cs # Auto-registration via IComposer +├── BackOfficeExamineSearcher.cs # Unified backoffice search (532 lines) +├── ConfigurationEnabledDirectoryFactory.cs # Directory factory selector +├── DeliveryApiContentIndex.cs # Headless API index with culture support +├── LuceneIndexDiagnostics.cs # Base diagnostics implementation +├── LuceneIndexDiagnosticsFactory.cs # Diagnostics factory +├── LuceneRAMDirectoryFactory.cs # In-memory directory for testing +├── NoPrefixSimpleFsLockFactory.cs # Lock factory without prefix +├── UmbracoApplicationRoot.cs # Application root path provider +├── UmbracoContentIndex.cs # Content/media index (158 lines) +├── UmbracoExamineIndex.cs # Base index class (153 lines) +├── UmbracoExamineIndexDiagnostics.cs # Extended diagnostics +├── UmbracoLockFactory.cs # Lock factory wrapper +├── UmbracoMemberIndex.cs # Member index +└── UmbracoTempEnvFileSystemDirectoryFactory.cs # Temp directory factory +``` + +### Index Hierarchy + +``` +LuceneIndex (Examine) + └── UmbracoExamineIndex (base for all Umbraco indexes) + ├── UmbracoContentIndex (content + media) + ├── UmbracoMemberIndex (members) + └── DeliveryApiContentIndex (headless API) +``` + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### Built-in Indexes (Constants.UmbracoIndexes) + +| Index Name | Type | Purpose | +|------------|------|---------| +| `InternalIndex` | UmbracoContentIndex | Backoffice search (all content) | +| `ExternalIndex` | UmbracoContentIndex | Frontend published content | +| `MembersIndex` | UmbracoMemberIndex | Member search | +| `DeliveryApiContentIndex` | DeliveryApiContentIndex | Headless API content | + +**Note:** See [ConfigureIndexOptions](#configureindexoptions-dependencyinjectionconfigureindexoptionscs) for per-index analyzer and validator configuration. + +### UmbracoExamineIndex (UmbracoExamineIndex.cs) + +Base class for all Umbraco indexes. Key features: + +**Runtime State Check** (lines 84-95): +```csharp +protected bool CanInitialize() +{ + var canInit = _runtimeState.Level == RuntimeLevel.Run; + // Logs warning once if runtime not ready +} +``` +Prevents indexing during install/upgrade. + +**Special Field Transformations** (lines 130-152): +- `__Path` field - Enables descendant queries via path matching +- `__Icon` field - Preserves icon for display + +**Raw Field Storage** (lines 101-118): +Fields prefixed with `__Raw_` are stored as `StoredField` (not analyzed), used for returning exact values. + +### UmbracoContentIndex (UmbracoContentIndex.cs) + +Handles content and media indexing with validation and cascade deletes. + +**Cascade Delete** (lines 128-157): +When content deleted, automatically finds and removes all descendants: +```csharp +var descendantPath = $@"\-1\,*{nodeId}\,*"; +var rawQuery = $"{UmbracoExamineFieldNames.IndexPathFieldName}:{descendantPath}"; +``` + +**Validation Groups** (lines 66-116): +- `Valid` - Index normally +- `Failed` - Skip (invalid data) +- `Filtered` - Delete from index (moved to recycle bin) + +### DeliveryApiContentIndex (DeliveryApiContentIndex.cs) + +Specialized index for Delivery API with culture support. + +**Key Differences** (lines 20-34): +- `ApplySpecialValueTransformations = false` - No path/icon transformations +- `PublishedValuesOnly = false` - Handles via populator +- `EnableDefaultEventHandler = false` - Custom event handling + +**Composite IDs** (lines 118-128): +Index IDs can be composite: `"1234|da-DK"` (content ID + culture) or simple: `"1234"`. + +### BackOfficeExamineSearcher (BackOfficeExamineSearcher.cs) + +Unified search for backoffice tree with user permissions. + +**Search Features**: +- Node name boosting (10x for exact match, line 316-327) +- Wildcard support for partial matches (line 350-376) +- User start node filtering (lines 378-456) +- Recycle bin filtering (lines 185-191) +- Multi-language variant search (all `nodeName_{lang}` fields) + +**Entity Type Routing** (lines 89-155): Member→MembersIndex, Media→InternalIndex, Document→InternalIndex + +--- + +## 4. Directory Factories + +### Configuration Options (IndexCreatorSettings.LuceneDirectoryFactory) + +- `Default` - FileSystemDirectoryFactory (standard filesystem storage) +- `TempFileSystemDirectoryFactory` - UmbracoTempEnvFileSystemDirectoryFactory (store in `%TEMP%/ExamineIndexes`) +- `SyncedTempFileSystemDirectoryFactory` - SyncedFileSystemDirectoryFactory (temp with sync to persistent) + +### UmbracoTempEnvFileSystemDirectoryFactory (lines 20-34) + +Creates unique temp path using site name + application identifier hash: +```csharp +var hashString = hostingEnvironment.SiteName + "::" + applicationIdentifier.GetApplicationUniqueIdentifier(); +var cachePath = Path.Combine(Path.GetTempPath(), "ExamineIndexes", appDomainHash); +``` + +**Purpose**: Prevents index collisions when same app moves between workers in load-balanced scenarios. + +### ConfigurationEnabledDirectoryFactory (lines 34-62) + +Selector that creates appropriate factory based on `IndexCreatorSettings.LuceneDirectoryFactory` config value. + +--- + +## 5. Index Configuration + +### ConfigureIndexOptions (DependencyInjection/ConfigureIndexOptions.cs) + +Configures per-index options via `IConfigureNamedOptions`. + +**Per-Index Settings** (lines 39-61): + +| Index | Analyzer | Validator | +|-------|----------|-----------| +| InternalIndex | CultureInvariantWhitespaceAnalyzer | ContentValueSetValidator | +| ExternalIndex | StandardAnalyzer | PublishedContentValueSetValidator | +| MembersIndex | CultureInvariantWhitespaceAnalyzer | MemberValueSetValidator | +| DeliveryApiContentIndex | StandardAnalyzer | None (populator handles) | + +**Global Settings** (lines 63-70): +- `UnlockIndex = true` - Always unlock on startup +- Snapshot deletion policy when using SyncedTempFileSystemDirectoryFactory + +--- + +## 6. Index Diagnostics + +### LuceneIndexDiagnostics (LuceneIndexDiagnostics.cs) + +Provides health checks and metadata for backoffice examine dashboard. + +**Health Check** (lines 41-45): +```csharp +public Attempt IsHealthy() +{ + var isHealthy = Index.IsHealthy(out Exception? indexError); + return isHealthy ? Attempt.Succeed() : Attempt.Fail(indexError?.Message); +} +``` + +**Metadata** (lines 51-92): +- CommitCount, DefaultAnalyzer, LuceneDirectory type +- LuceneIndexFolder (relative path) +- DirectoryFactory and IndexDeletionPolicy types + +### UmbracoExamineIndexDiagnostics (UmbracoExamineIndexDiagnostics.cs) + +Extends base with Umbraco-specific metadata (lines 23-48): +- EnableDefaultEventHandler +- PublishedValuesOnly +- Validator settings (IncludeItemTypes, ExcludeItemTypes, etc.) + +--- + +## 7. Project-Specific Notes + +### Auto-Registration + +`AddExamineComposer` (lines 10-14) automatically registers all Examine services: +```csharp +builder + .AddExamine() + .AddExamineIndexes(); +``` + +### Service Registrations (UmbracoBuilderExtensions.cs) + +Key services registered (lines 18-62): `IBackOfficeExamineSearcher`, `IIndexDiagnosticsFactory`, `IApplicationRoot`, `ILockFactory`, plus all 4 indexes with `ConfigurationEnabledDirectoryFactory`. + +### Known Technical Debt + +1. **Warning Suppression** (`.csproj:10-14`): `CS0618` - Uses obsolete members in Examine/Lucene.NET + +2. **TODO: Raw Query Support** (`BackOfficeExamineSearcher.cs:71-76`): + ```csharp + // TODO: WE should try to allow passing in a lucene raw query, however we will still need to do some manual string + // manipulation for things like start paths, member types, etc... + ``` + +3. **TODO: Query Parsing** (`ExamineExtensions.cs:21-22`): + ```csharp + // TODO: I'd assume there would be a more strict way to parse the query but not that i can find yet + ``` + +### Index Field Names + +Key fields from `UmbracoExamineFieldNames` (defined in Umbraco.Core): +- `__Path` - Content path for descendant queries +- `__Icon` - Content icon +- `__Raw_*` - Raw stored values (not analyzed) +- `__Key` - Content GUID +- `__NodeTypeAlias` - Content type alias +- `__IndexType` - `content`, `media`, or `member` + +### Lucene Query Escaping + +`BackOfficeExamineSearcher.BuildQuery()` (lines 193-311) handles: +- Special characters `*`, `-`, `_` removal/replacement +- Quoted phrase search (`"exact match"`) +- Query escaping via `QueryParserBase.Escape()` +- Path escaping (replace `-` with `\-`, `,` with `\,`) + +### User Start Node Filtering + +`BackOfficeExamineSearcher.AppendPath()` (lines 378-456) ensures users only see content they have access to: +1. Gets user's start nodes from `IBackOfficeSecurityAccessor` +2. Filters search results to paths under start nodes +3. Returns empty results if searchFrom outside user's access + +--- + +## Quick Reference + +**Index Names** (Constants.UmbracoIndexes): +- `InternalIndexName` = "InternalIndex" +- `ExternalIndexName` = "ExternalIndex" +- `MembersIndexName` = "MembersIndex" +- `DeliveryApiContentIndexName` = "DeliveryApiContentIndex" + +**Key Interfaces**: +- `IUmbracoIndex` - Marker for Umbraco indexes +- `IUmbracoContentIndex` - Content index marker +- `IBackOfficeExamineSearcher` - Backoffice search service +- `IIndexDiagnostics` - Health/metadata for dashboard + +**Dependencies**: +- `Umbraco.Infrastructure` - Core services, index populators +- `Umbraco.Core` - Field names, constants, interfaces +- `Examine` (NuGet) - Lucene.NET abstraction diff --git a/src/Umbraco.Infrastructure/Install/InstallHelper.cs b/src/Umbraco.Infrastructure/Install/InstallHelper.cs index d04a0f99bf06..e067b1579159 100644 --- a/src/Umbraco.Infrastructure/Install/InstallHelper.cs +++ b/src/Umbraco.Infrastructure/Install/InstallHelper.cs @@ -8,23 +8,12 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.Migrations.Install; using Umbraco.Cms.Infrastructure.Persistence; -using Umbraco.Extensions; -using Constants = Umbraco.Cms.Core.Constants; namespace Umbraco.Cms.Infrastructure.Install { + [Obsolete("InstallHelper is no longer used internally and will be removed in Umbraco 19.")] public sealed class InstallHelper { - private readonly DatabaseBuilder _databaseBuilder; - private readonly ILogger _logger; - private readonly IUmbracoVersion _umbracoVersion; - private readonly IOptionsMonitor _connectionStrings; - private readonly IInstallationService _installationService; - private readonly ICookieManager _cookieManager; - private readonly IUserAgentProvider _userAgentProvider; - private readonly IUmbracoDatabaseFactory _umbracoDatabaseFactory; - private readonly IFireAndForgetRunner _fireAndForgetRunner; - private readonly IEnumerable _databaseProviderMetadata; public InstallHelper( DatabaseBuilder databaseBuilder, @@ -38,74 +27,12 @@ public InstallHelper( IFireAndForgetRunner fireAndForgetRunner, IEnumerable databaseProviderMetadata) { - _logger = logger; - _umbracoVersion = umbracoVersion; - _databaseBuilder = databaseBuilder; - _connectionStrings = connectionStrings; - _installationService = installationService; - _cookieManager = cookieManager; - _userAgentProvider = userAgentProvider; - _umbracoDatabaseFactory = umbracoDatabaseFactory; - _fireAndForgetRunner = fireAndForgetRunner; - _databaseProviderMetadata = databaseProviderMetadata; - } - - public Task SetInstallStatusAsync(bool isCompleted, string errorMsg) - { - try - { - var userAgent = _userAgentProvider.GetUserAgent(); - - // Check for current install ID - var installCookie = _cookieManager.GetCookieValue(Constants.Web.InstallerCookieName); - if (!Guid.TryParse(installCookie, out Guid installId)) - { - installId = Guid.NewGuid(); - - _cookieManager.SetCookieValue(Constants.Web.InstallerCookieName, installId.ToString(), false, false, "Unspecified"); - } - - var dbProvider = string.Empty; - if (IsBrandNewInstall == false) - { - // we don't have DatabaseProvider anymore... doing it differently - //dbProvider = ApplicationContext.Current.DatabaseContext.DatabaseProvider.ToString(); - dbProvider = _umbracoDatabaseFactory.SqlContext.SqlSyntax.DbProvider; - } - - var installLog = new InstallLog( - installId: installId, - isUpgrade: IsBrandNewInstall == false, - installCompleted: isCompleted, - timestamp: DateTime.Now, - versionMajor: _umbracoVersion.Version.Major, - versionMinor: _umbracoVersion.Version.Minor, - versionPatch: _umbracoVersion.Version.Build, - versionComment: _umbracoVersion.Comment, - error: errorMsg, - userAgent: userAgent, - dbProvider: dbProvider); - - _fireAndForgetRunner.RunFireAndForget(() => _installationService.LogInstall(installLog)); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred in InstallStatus trying to check upgrades"); - } - - return Task.CompletedTask; } /// - /// Checks if this is a brand new install, meaning that there is no configured database connection or the database is empty. + /// This method used to send installer telemetry to Our.Umbraco.com but no longer does anything and will be removed in Umbraco 19. /// - /// - /// true if this is a brand new install; otherwise, false. - /// - private bool IsBrandNewInstall => - _connectionStrings.CurrentValue.IsConnectionStringConfigured() == false || - _databaseBuilder.IsDatabaseConfigured == false || - (_databaseBuilder.CanConnectToDatabase == false && _databaseProviderMetadata.CanForceCreateDatabase(_umbracoDatabaseFactory)) || - _databaseBuilder.IsUmbracoInstalled() == false; + [Obsolete("SetInstallStatusAsync no longer has any function and will be removed in Umbraco 19.")] + public Task SetInstallStatusAsync(bool isCompleted, string errorMsg) => Task.CompletedTask; } } diff --git a/src/Umbraco.Infrastructure/Installer/Steps/RegisterInstallCompleteStep.cs b/src/Umbraco.Infrastructure/Installer/Steps/RegisterInstallCompleteStep.cs index b7717d9fc4df..99c973c6fb13 100644 --- a/src/Umbraco.Infrastructure/Installer/Steps/RegisterInstallCompleteStep.cs +++ b/src/Umbraco.Infrastructure/Installer/Steps/RegisterInstallCompleteStep.cs @@ -7,19 +7,21 @@ namespace Umbraco.Cms.Infrastructure.Installer.Steps; public class RegisterInstallCompleteStep : StepBase, IInstallStep, IUpgradeStep { - private readonly InstallHelper _installHelper; + [Obsolete("Please use the constructor without parameters. Scheduled for removal in Umbraco 19.")] + public RegisterInstallCompleteStep(InstallHelper installHelper) + : this() + { + } - public RegisterInstallCompleteStep(InstallHelper installHelper) => _installHelper = installHelper; + public RegisterInstallCompleteStep() + { + } public Task> ExecuteAsync(InstallData _) => Execute(); public Task> ExecuteAsync() => Execute(); - private async Task> Execute() - { - await _installHelper.SetInstallStatusAsync(true, string.Empty); - return Success(); - } + private Task> Execute() => Task.FromResult(Success()); public Task RequiresExecutionAsync(InstallData _) => ShouldExecute(); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 3e0ecb7f94f3..3dc5f2e7a5c7 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -141,6 +141,9 @@ protected virtual void DefinePlan() To("{8B2C830A-4FFB-4433-8337-8649B0BF52C8}"); To("{1C38D589-26BB-4A46-9ABE-E4A0DF548A87}"); + // To 17.0.1 + To("{BE5CA411-E12D-4455-A59E-F12A669E5363}"); + // To 18.0.0 // TODO (V18): Enable on 18 branch //// To("{74332C49-B279-4945-8943-F8F00B1F5949}"); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs index f3914b854c04..73508bcc7b9e 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs @@ -17,6 +17,13 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0; +/// +/// Migrates local links in content and media properties from the legacy format using UDIs +/// to the new one with GUIDs. +/// +/// +/// See: https://github.com/umbraco/Umbraco-CMS/pull/17307. +/// public class ConvertLocalLinks : MigrationBase { private readonly IUmbracoContextFactory _umbracoContextFactory; @@ -30,7 +37,9 @@ public class ConvertLocalLinks : MigrationBase private readonly ICoreScopeProvider _coreScopeProvider; private readonly LocalLinkMigrationTracker _linkMigrationTracker; - [Obsolete("Use non obsoleted contructor instead")] + /// + /// Initializes a new instance of the class. + /// public ConvertLocalLinks( IMigrationContext context, IUmbracoContextFactory umbracoContextFactory, @@ -57,6 +66,10 @@ public ConvertLocalLinks( _linkMigrationTracker = linkMigrationTracker; } + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal along with all other migrations to 17 in Umbraco 18.")] public ConvertLocalLinks( IMigrationContext context, IUmbracoContextFactory umbracoContextFactory, @@ -83,6 +96,7 @@ public ConvertLocalLinks( { } + /// protected override void Migrate() { IEnumerable propertyEditorAliases = _localLinkProcessor.GetSupportedPropertyEditorAliases(); @@ -116,7 +130,7 @@ protected override void Migrate() _logger.LogInformation( "Migration starting for all properties of type: {propertyEditorAlias}", propertyEditorAlias); - if (ProcessPropertyTypes(propertyTypes, languagesById)) + if (ProcessPropertyTypes(propertyEditorAlias, propertyTypes, languagesById)) { _logger.LogInformation( "Migration succeeded for all properties of type: {propertyEditorAlias}", @@ -134,7 +148,7 @@ protected override void Migrate() RebuildCache = true; } - private bool ProcessPropertyTypes(IPropertyType[] propertyTypes, IDictionary languagesById) + private bool ProcessPropertyTypes(string propertyEditorAlias, IPropertyType[] propertyTypes, IDictionary languagesById) { foreach (IPropertyType propertyType in propertyTypes) { @@ -145,112 +159,157 @@ private bool ProcessPropertyTypes(IPropertyType[] propertyTypes, IDictionary sql = Sql() - .Select() - .From() - .InnerJoin() - .On((propertyData, contentVersion) => - propertyData.VersionId == contentVersion.Id) - .LeftJoin() - .On((contentVersion, documentVersion) => - contentVersion.Id == documentVersion.Id) - .Where( - (propertyData, contentVersion, documentVersion) => - (contentVersion.Current == true || documentVersion.Published == true) - && propertyData.PropertyTypeId == propertyType.Id); - - List propertyDataDtos = Database.Fetch(sql); - if (propertyDataDtos.Count < 1) + long propertyDataCount = Database.ExecuteScalar(BuildPropertyDataSql(propertyType, true)); + if (propertyDataCount == 0) { continue; } - var updateBatch = propertyDataDtos.Select(propertyDataDto => - UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))).ToList(); - - var updatesToSkip = new ConcurrentBag>(); - - var progress = 0; + _logger.LogInformation( + "Migrating {PropertyDataCount} property data values for property {PropertyTypeAlias} ({PropertyTypeKey}) with property editor alias {PropertyEditorAlias}", + propertyDataCount, + propertyType.Alias, + propertyType.Key, + propertyEditorAlias); - void HandleUpdateBatch(UpdateBatch update) + // Process in pages to avoid loading all property data from the database into memory at once. + Sql sql = BuildPropertyDataSql(propertyType); + const int PageSize = 10000; + long pageNumber = 1; + long pageCount = (propertyDataCount + PageSize - 1) / PageSize; + int processedCount = 0; + while (processedCount < propertyDataCount) { - using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); - - progress++; - if (progress % 100 == 0) + Page propertyDataDtoPage = Database.Page(pageNumber, PageSize, sql); + if (propertyDataDtoPage.Items.Count == 0) { - _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, - updateBatch.Count); + break; } - PropertyDataDto propertyDataDto = update.Poco; + var updateBatchCollection = propertyDataDtoPage.Items + .Select(propertyDataDto => + UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))) + .ToList(); - if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor) == false) + var updatesToSkip = new ConcurrentBag>(); + + var progress = 0; + + void HandleUpdateBatch(UpdateBatch update) { - updatesToSkip.Add(update); + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + + progress++; + if (progress % 100 == 0) + { + _logger.LogInformation( + " - finished {Progress} of {PageTotal} properties in page {PageNumber} of {PageCount}", + progress, + updateBatchCollection.Count, + pageNumber, + pageCount); + } + + PropertyDataDto propertyDataDto = update.Poco; + + if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor) == false) + { + updatesToSkip.Add(update); + } } - } - if (DatabaseType == DatabaseType.SQLite) - { - // SQLite locks up if we run the migration in parallel, so... let's not. - foreach (UpdateBatch update in updateBatch) + if (DatabaseType == DatabaseType.SQLite) { - HandleUpdateBatch(update); + // SQLite locks up if we run the migration in parallel, so... let's not. + foreach (UpdateBatch update in updateBatchCollection) + { + HandleUpdateBatch(update); + } } - } - else - { - Parallel.ForEachAsync(updateBatch, async (update, token) => + else { - //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task - Task task; - using (ExecutionContext.SuppressFlow()) + Parallel.ForEachAsync(updateBatchCollection, async (update, token) => { - task = Task.Run( - () => - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.Complete(); - HandleUpdateBatch(update); - }, - token); - } + //Foreach here, but we need to suppress the flow before each task, but not the actual await of the task + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + HandleUpdateBatch(update); + }, + token); + } + + await task; + }).GetAwaiter().GetResult(); + } - await task; - }).GetAwaiter().GetResult(); - } + updateBatchCollection.RemoveAll(updatesToSkip.Contains); - updateBatch.RemoveAll(updatesToSkip.Contains); + if (updateBatchCollection.Any() is false) + { + _logger.LogDebug(" - no properties to convert, continuing"); - if (updateBatch.Any() is false) - { - _logger.LogDebug(" - no properties to convert, continuing"); - continue; - } + pageNumber++; + processedCount += propertyDataDtoPage.Items.Count; - _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatch.Count); - var result = Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 }); - if (result != updateBatch.Count) - { - throw new InvalidOperationException( - $"The database batch update was supposed to update {updateBatch.Count} property DTO entries, but it updated {result} entries."); - } + continue; + } - _logger.LogDebug( - "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", - propertyType.Name, - propertyType.Id, - propertyType.Alias, - propertyType.PropertyEditorAlias, - result); + _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatchCollection.Count); + var result = Database.UpdateBatch(updateBatchCollection, new BatchOptions { BatchSize = 100 }); + if (result != updateBatchCollection.Count) + { + throw new InvalidOperationException( + $"The database batch update was supposed to update {updateBatchCollection.Count} property DTO entries, but it updated {result} entries."); + } + + _logger.LogDebug( + "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", + propertyType.Name, + propertyType.Id, + propertyType.Alias, + propertyType.PropertyEditorAlias, + result); + + pageNumber++; + processedCount += propertyDataDtoPage.Items.Count; + } } return true; } - private bool ProcessPropertyDataDto(PropertyDataDto propertyDataDto, IPropertyType propertyType, - IDictionary languagesById, IDataValueEditor valueEditor) + private Sql BuildPropertyDataSql(IPropertyType propertyType, bool isCount = false) + { + Sql sql = isCount + ? Sql().SelectCount() + : Sql().Select(); + + sql = sql.From() + .InnerJoin() + .On((propertyData, contentVersion) => + propertyData.VersionId == contentVersion.Id) + .LeftJoin() + .On((contentVersion, documentVersion) => + contentVersion.Id == documentVersion.Id) + .Where( + (propertyData, contentVersion, documentVersion) => + (contentVersion.Current || documentVersion.Published) + && propertyData.PropertyTypeId == propertyType.Id); + + return sql; + } + + private bool ProcessPropertyDataDto( + PropertyDataDto propertyDataDto, + IPropertyType propertyType, + IDictionary languagesById, + IDataValueEditor valueEditor) { // NOTE: some old property data DTOs can have variance defined, even if the property type no longer varies var culture = propertyType.VariesByCulture() diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs index c4b1f3da4753..6f290d455f7b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs @@ -96,6 +96,14 @@ protected override void Migrate() using IScope scope = _scopeProvider.CreateScope(); using IDisposable notificationSuppression = scope.Notifications.Suppress(); + // Ensure we have a long command timeout as this migration can take a while on large tables within the database. + // If the command timeout is already longer, applied via the connection string with "Connect Timeout={timeout}" we leave it as is. + const int CommandTimeoutInSeconds = 300; + if (scope.Database.CommandTimeout < CommandTimeoutInSeconds) + { + scope.Database.CommandTimeout = CommandTimeoutInSeconds; + } + MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZone); MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZone); MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZone); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_1/EnsureUmbracoPropertyDataColumnCasing.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_1/EnsureUmbracoPropertyDataColumnCasing.cs new file mode 100644 index 000000000000..6c97c075dd73 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_1/EnsureUmbracoPropertyDataColumnCasing.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using NPoco; +using static Umbraco.Cms.Core.Constants; +using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_1; + +/// +/// Ensures the propertyTypeId column in umbracoPropertyData has correct camel case naming. +/// +/// +/// SQL Server is case sensitive for columns used in a SQL Bulk insert statement (which is used in publishing +/// operations on umbracoPropertyData). +/// Earlier versions of Umbraco used all lower case for the propertyTypeId column name (propertytypeid), whereas newer versions +/// use camel case (propertyTypeId). +/// +public class EnsureUmbracoPropertyDataColumnCasing : AsyncMigrationBase +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public EnsureUmbracoPropertyDataColumnCasing(IMigrationContext context, ILogger logger) + : base(context) => _logger = logger; + + /// + protected override Task MigrateAsync() + { + // We only need to do this for SQL Server. + if (DatabaseType == DatabaseType.SQLite) + { + return Task.CompletedTask; + } + + const string oldColumnName = "propertytypeid"; + const string newColumnName = "propertyTypeId"; + ColumnInfo[] columns = [.. SqlSyntax.GetColumnsInSchema(Context.Database)]; + ColumnInfo? targetColumn = columns + .FirstOrDefault(x => x.TableName == DatabaseSchema.Tables.PropertyData && string.Equals(x.ColumnName, oldColumnName, StringComparison.InvariantCulture)); + if (targetColumn is not null) + { + // The column exists with incorrect casing, we need to rename it. + Rename.Column(oldColumnName) + .OnTable(DatabaseSchema.Tables.PropertyData) + .To(newColumnName) + .Do(); + + _logger.LogInformation("Renamed column {OldColumnName} to {NewColumnName} on table {TableName}", oldColumnName, newColumnName, DatabaseSchema.Tables.PropertyData); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/CLAUDE.md b/src/Umbraco.PublishedCache.HybridCache/CLAUDE.md new file mode 100644 index 000000000000..b881d185ac8a --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/CLAUDE.md @@ -0,0 +1,362 @@ +# Umbraco.PublishedCache.HybridCache + +Published content caching layer for Umbraco CMS using Microsoft's HybridCache (in-memory + optional distributed cache). Provides high-performance content delivery with cache seeding, serialization, and notification-based invalidation. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Package ID**: Umbraco.Cms.PublishedCache.HybridCache +**Namespace**: Umbraco.Cms.Infrastructure.HybridCache +**Dependencies**: Umbraco.Core, Umbraco.Infrastructure, Microsoft.Extensions.Caching.Hybrid, MessagePack, K4os.Compression.LZ4 + +--- + +## 1. Architecture + +### Project Purpose + +This project implements the published content cache using Microsoft's HybridCache pattern: + +1. **Multi-Level Caching** - L1 (in-memory) + L2 (optional distributed cache like Redis) +2. **Cache Seeding** - Pre-populates cache on startup with frequently accessed content +3. **MessagePack Serialization** - Fast binary serialization with LZ4 compression +4. **Notification-Based Invalidation** - Automatic cache updates on content changes +5. **Draft/Published Separation** - Separate cache entries for draft and published content + +### Folder Structure + +``` +Umbraco.PublishedCache.HybridCache/ +├── DependencyInjection/ +│ └── UmbracoBuilderExtensions.cs # AddUmbracoHybridCache() registration (90 lines) +├── Extensions/ +│ └── HybridCacheExtensions.cs # ExistsAsync extension method +├── Factories/ +│ ├── CacheNodeFactory.cs # Creates ContentCacheNode from IContent +│ ├── ICacheNodeFactory.cs +│ ├── IPublishedContentFactory.cs +│ └── PublishedContentFactory.cs # Creates IPublishedContent from cache +├── NotificationHandlers/ +│ ├── CacheRefreshingNotificationHandler.cs # Content/media change handler (120 lines) +│ ├── HybridCacheStartupNotificationHandler.cs +│ └── SeedingNotificationHandler.cs # Startup seeding (39 lines) +├── Persistence/ +│ ├── ContentSourceDto.cs # DTO for database queries +│ ├── DatabaseCacheRepository.cs # NPoco database access (891 lines) +│ └── IDatabaseCacheRepository.cs +├── SeedKeyProviders/ +│ ├── BreadthFirstKeyProvider.cs # Base breadth-first traversal +│ ├── Document/ +│ │ ├── ContentTypeSeedKeyProvider.cs # Seeds by content type +│ │ └── DocumentBreadthFirstKeyProvider.cs # Seeds top-level content (83 lines) +│ └── Media/ +│ └── MediaBreadthFirstKeyProvider.cs +├── Serialization/ +│ ├── ContentCacheDataModel.cs # Serializable cache model +│ ├── HybridCacheSerializer.cs # MessagePack serializer (40 lines) +│ ├── IContentCacheDataSerializer.cs # Nested data serializer interface +│ ├── JsonContentNestedDataSerializer.cs # JSON serializer option +│ ├── MsgPackContentNestedDataSerializer.cs # MessagePack serializer option +│ └── LazyCompressedString.cs # Lazy LZ4 compression +├── Services/ +│ ├── DocumentCacheService.cs # Content caching service (372 lines) +│ ├── MediaCacheService.cs # Media caching service +│ ├── MemberCacheService.cs # Member caching service +│ └── DomainCacheService.cs # Domain caching service +├── CacheManager.cs # ICacheManager facade (27 lines) +├── ContentCacheNode.cs # Cache entry model (24 lines) +├── ContentData.cs # Content data container +├── ContentNode.cs # Content node representation +├── DatabaseCacheRebuilder.cs # Full cache rebuild +├── DocumentCache.cs # IPublishedContentCache impl (58 lines) +├── MediaCache.cs # IPublishedMediaCache impl +├── MemberCache.cs # IPublishedMemberCache impl +├── DomainCache.cs # IDomainCache impl +└── PublishedContent.cs # IPublishedContent impl +``` + +### Cache Architecture + +``` +Request → DocumentCache (IPublishedContentCache) + ↓ + DocumentCacheService + ↓ + ┌─────────────────────────┐ + │ HybridCache │ + │ ┌───────┐ ┌────────┐ │ + │ │ L1 │ │ L2 │ │ + │ │Memory │→ │ Redis │ │ + │ └───────┘ └────────┘ │ + └─────────────────────────┘ + ↓ (cache miss) + DatabaseCacheRepository + ↓ + cmsContentNu table +``` + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### HybridCache Configuration (UmbracoBuilderExtensions.cs) + +**Registration** (lines 30-36): +```csharp +builder.Services.AddHybridCache(options => +{ + // Default 100MB max payload (configurable) + options.MaximumPayloadBytes = 1024 * 1024 * 100; +}).AddSerializer(); +``` + +**Key Services Registered** (lines 38-52): +- `IPublishedContentCache` → `DocumentCache` +- `IPublishedMediaCache` → `MediaCache` +- `IPublishedMemberCache` → `MemberCache` +- `IDomainCache` → `DomainCache` +- `IDocumentCacheService` → `DocumentCacheService` +- `IMediaCacheService` → `MediaCacheService` +- `ICacheManager` → `CacheManager` + +### DocumentCacheService (Services/DocumentCacheService.cs) + +Core caching logic for content. + +**GetNodeAsync** (lines 110-155): +1. Check in-memory `_publishedContentCache` dictionary +2. Call `HybridCache.GetOrCreateAsync()` with database fallback +3. Verify published ancestor path exists +4. Convert `ContentCacheNode` to `IPublishedContent` + +**Cache Key Format** (line 326): +```csharp +private static string GetCacheKey(Guid key, bool preview) + => preview ? $"{key}+draft" : $"{key}"; +``` + +**Cache Entry Options** (lines 275-287): +- Seed entries use `SeedCacheDuration` from `CacheSettings` +- Regular entries use `LocalCacheDuration` and `RemoteCacheDuration` +- Preview/draft entries always use regular (non-seed) durations + +### ContentCacheNode (ContentCacheNode.cs) + +Immutable cache entry model (marked `[ImmutableObject(true)]` for HybridCache performance): +- `Id`, `Key`, `SortOrder`, `CreateDate`, `CreatorId` +- `ContentTypeId`, `IsDraft` +- `ContentData? Data` - Property values, culture data, URL segments + +### HybridCacheSerializer (Serialization/HybridCacheSerializer.cs) + +MessagePack serialization with LZ4 compression: +```csharp +_options = defaultOptions + .WithCompression(MessagePackCompression.Lz4BlockArray) + .WithSecurity(MessagePackSecurity.UntrustedData); +``` + +--- + +## 4. Cache Seeding + +### Seed Key Providers + +| Provider | Purpose | Config Setting | +|----------|---------|----------------| +| `ContentTypeSeedKeyProvider` | Seeds specific content types | N/A | +| `DocumentBreadthFirstKeyProvider` | Seeds top-level content first | `DocumentBreadthFirstSeedCount` | +| `MediaBreadthFirstKeyProvider` | Seeds top-level media first | `MediaBreadthFirstSeedCount` | + +### DocumentBreadthFirstKeyProvider (lines 28-82) + +Breadth-first traversal that: +1. Gets root content keys +2. Filters to only published content (any culture) +3. Traverses children breadth-first until seed count reached +4. Only includes content with published ancestor path + +### SeedingNotificationHandler (lines 27-38) + +Runs on `UmbracoApplicationStartingNotification`: +- Skips during Install or Upgrade (when maintenance page shown) +- Calls `DocumentCacheService.SeedAsync()` then `MediaCacheService.SeedAsync()` + +### Seed Process (DocumentCacheService.SeedAsync lines 205-264) + +1. Collect seed keys from all `IDocumentSeedKeyProvider` instances +2. Process in batches of `DocumentSeedBatchSize` +3. Check if key already in cache via `ExistsAsync()` +4. Batch fetch uncached keys from database +5. Set in cache with seed entry options + +--- + +## 5. Cache Invalidation + +### CacheRefreshingNotificationHandler (120 lines) + +Handles 8 notification types: + +| Notification | Action | +|--------------|--------| +| `ContentRefreshNotification` | `RefreshContentAsync()` | +| `ContentDeletedNotification` | `DeleteItemAsync()` for each | +| `MediaRefreshNotification` | `RefreshMediaAsync()` | +| `MediaDeletedNotification` | `DeleteItemAsync()` for each | +| `ContentTypeRefreshedNotification` | Clear type cache, `Rebuild()` | +| `ContentTypeDeletedNotification` | Clear type cache | +| `MediaTypeRefreshedNotification` | Clear type cache, `Rebuild()` | +| `MediaTypeDeletedNotification` | Clear type cache | + +### RefreshContentAsync (DocumentCacheService.cs lines 300-324) + +1. Creates draft cache node from `IContent` +2. Persists to `cmsContentNu` table +3. If publishing: creates published node and persists +4. If unpublishing: clears published cache entry + +### Cache Tags (line 331) + +All content uses tag `Constants.Cache.Tags.Content` for bulk invalidation: +```csharp +await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken); +``` + +--- + +## 6. Database Persistence + +### DatabaseCacheRepository (891 lines) + +NPoco-based repository for `cmsContentNu` table. + +**Key Methods**: +- `GetContentSourceAsync(Guid key, bool preview)` - Single content fetch +- `GetContentSourcesAsync(IEnumerable keys)` - Batch fetch +- `RefreshContentAsync(ContentCacheNode, PublishedState)` - Insert/update cache +- `Rebuild()` - Full cache rebuild by content type + +**SQL Templates** (lines 575-748): +Uses `SqlContext.Templates` for cached SQL generation with optimized joins across: +- `umbracoNode`, `umbracoContent`, `cmsDocument`, `umbracoContentVersion` +- `cmsDocumentVersion`, `cmsContentNu` + +### Serialization Options (NuCacheSettings.NuCacheSerializerType) + +| Type | Serializer | +|------|------------| +| `JSON` | JsonContentNestedDataSerializer | +| `MessagePack` | MsgPackContentNestedDataSerializer | + +--- + +## 7. Project-Specific Notes + +### Experimental API Warning + +HybridCache API is experimental (suppressed with `#pragma warning disable EXTEXP0018`). + +### Draft vs Published Caching + +- **Draft cache key**: `"{key}+draft"` +- **Published cache key**: `"{key}"` +- Each stored separately to avoid cross-contamination +- Draft entries never use seed durations + +### Published Ancestor Path Check (DocumentCacheService.cs lines 131-135) + +Before returning cached content, verifies ancestor path is published via `_publishStatusQueryService.HasPublishedAncestorPath()`. Returns null if parent unpublished. + +### In-Memory Content Cache (DocumentCacheService.cs line 39) + +Secondary `ConcurrentDictionary` caches converted objects, since `ContentCacheNode` to `IPublishedContent` conversion is expensive. + +### Known Technical Debt + +1. **Warnings Disabled** (`.csproj:10-12`): `TreatWarningsAsErrors=false` +2. **Property Value Null Handling** (`PropertyData.cs:25, 37`): Cannot be null, needs fallback decision +3. **Routing Cache TODO** (`ContentCacheDataModel.cs:26`): Remove when routing cache implemented +4. **SQL Template Limitations** (`DatabaseCacheRepository.cs:612, 688, 737`): Can't use templates with certain joins +5. **Serializer Refactor** (`DatabaseCacheRepository.cs:481`): Standardize to ContentTypeId only +6. **String Interning** (`MsgPackContentNestedDataSerializer.cs:29`): Intern alias strings during deserialization +7. **V18 Interface Cleanup** (`IDatabaseCacheRepository.cs:24, 48`): Remove default implementations + +### Cache Settings (CacheSettings) + +Key configuration options: +- `DocumentBreadthFirstSeedCount` - Number of documents to seed +- `MediaBreadthFirstSeedCount` - Number of media items to seed +- `DocumentSeedBatchSize` - Batch size for seeding +- `Entry.Document.SeedCacheDuration` - Duration for seeded entries +- `Entry.Document.LocalCacheDuration` - L1 cache duration +- `Entry.Document.RemoteCacheDuration` - L2 cache duration + +### InternalsVisibleTo + +Test projects with access to internal types: +- `Umbraco.Tests` +- `Umbraco.Tests.Common` +- `Umbraco.Tests.UnitTests` +- `Umbraco.Tests.Integration` +- `DynamicProxyGenAssembly2` (for mocking) + +--- + +## Quick Reference + +### Cache Flow + +``` +GetByIdAsync(id) → GetByKeyAsync(key) → GetNodeAsync(key, preview) + → HybridCache.GetOrCreateAsync() + → DatabaseCacheRepository.GetContentSourceAsync() [on miss] + → PublishedContentFactory.ToIPublishedContent() + → CreateModel(IPublishedModelFactory) +``` + +### Key Interfaces + +| Interface | Implementation | Purpose | +|-----------|----------------|---------| +| `IPublishedContentCache` | DocumentCache | Content retrieval | +| `IPublishedMediaCache` | MediaCache | Media retrieval | +| `IDocumentCacheService` | DocumentCacheService | Cache operations | +| `IDatabaseCacheRepository` | DatabaseCacheRepository | Database access | +| `ICacheNodeFactory` | CacheNodeFactory | Cache node creation | + +### Configuration Keys + +```json +{ + "Umbraco": { + "CMS": { + "Cache": { + "DocumentBreadthFirstSeedCount": 100, + "MediaBreadthFirstSeedCount": 50, + "DocumentSeedBatchSize": 100, + "Entry": { + "Document": { + "SeedCacheDuration": "04:00:00", + "LocalCacheDuration": "00:05:00", + "RemoteCacheDuration": "01:00:00" + } + } + } + } + } +} +``` + +### Related Projects + +| Project | Relationship | +|---------|--------------| +| `Umbraco.Core` | Interfaces (IPublishedContentCache, etc.) | +| `Umbraco.Infrastructure` | Services, repositories, NPoco | +| Microsoft.Extensions.Caching.Hybrid | HybridCache implementation | diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs index 0ba4b6873bd6..17f37166307e 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -187,10 +187,10 @@ private CacheValues GetCacheValues(PropertyCacheLevel cacheLevel) switch (cacheLevel) { case PropertyCacheLevel.None: + case PropertyCacheLevel.Snapshot: // Snapshot is obsolete, so for now treat as None // never cache anything cacheValues = new CacheValues(); break; - case PropertyCacheLevel.Snapshot: // Snapshot is obsolete, so for now treat as element case PropertyCacheLevel.Element: // cache within the property object itself, ie within the content object cacheValues = _cacheValues ??= new CacheValues(); diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs index 854699cd29d1..0d7aad9d4978 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs @@ -27,15 +27,7 @@ public void ExpireCookie(string cookieName) return; } - var cookieValue = httpContext.Request.Cookies[cookieName]; - - httpContext.Response.Cookies.Append( - cookieName, - cookieValue ?? string.Empty, - new CookieOptions - { - Expires = DateTime.Now.AddYears(-1), - }); + httpContext.Response.Cookies.Delete(cookieName); } /// @@ -45,15 +37,14 @@ public void ExpireCookie(string cookieName) public void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode) { 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, + }; + + _httpContextAccessor.HttpContext?.Response.Cookies.Append(cookieName, value, options); } private static SameSiteMode ParseSameSiteMode(string sameSiteMode) => diff --git a/src/Umbraco.Web.Common/CLAUDE.md b/src/Umbraco.Web.Common/CLAUDE.md new file mode 100644 index 000000000000..3731cfd7e01e --- /dev/null +++ b/src/Umbraco.Web.Common/CLAUDE.md @@ -0,0 +1,380 @@ +# Umbraco.Web.Common + +Shared ASP.NET Core web functionality for Umbraco CMS. Provides controllers, middleware, application builder extensions, security/identity, localization, and the UmbracoContext request pipeline. + +**Project Type**: Class Library (NuGet package) +**Target Framework**: net10.0 +**Package ID**: Umbraco.Cms.Web.Common +**Namespace**: Umbraco.Cms.Web.Common +**Dependencies**: Umbraco.Examine.Lucene, Umbraco.PublishedCache.HybridCache, MiniProfiler, Serilog, Asp.Versioning + +--- + +## 1. Architecture + +### Project Purpose + +This project provides the web layer foundation for Umbraco CMS: + +1. **DI Registration** - `AddUmbraco()` entry point for service registration +2. **Application Pipeline** - `UmbracoApplicationBuilder` for middleware ordering +3. **Controllers** - Base classes for frontend and backoffice controllers +4. **UmbracoContext** - Request-scoped context with published content access +5. **Security** - Member sign-in, identity management, authentication middleware +6. **Middleware** - Boot failure handling, preview authentication, request routing + +### Folder Structure + +``` +Umbraco.Web.Common/ +├── ApplicationBuilder/ +│ ├── IUmbracoApplicationBuilder.cs # Builder interface +│ ├── IUmbracoPipelineFilter.cs # Pipeline customization hooks +│ └── UmbracoApplicationBuilder.cs # Middleware orchestration (170 lines) +├── AspNetCore/ +│ ├── AspNetCoreIpResolver.cs # IP address resolution +│ ├── AspNetCorePasswordHasher.cs # Identity password hashing +│ ├── AspNetCoreRequestAccessor.cs # Request access abstraction +│ └── OptionsMonitorAdapter.cs # Config monitoring wrapper +├── Controllers/ +│ ├── IRenderController.cs # Frontend controller marker +│ ├── IVirtualPageController.cs # Virtual page support +│ ├── PluginController.cs # Plugin controller base (104 lines) +│ ├── UmbracoApiController.cs # Legacy API controller (obsolete) +│ ├── UmbracoAuthorizedController.cs # Backoffice authorized base +│ └── UmbracoController.cs # Base MVC controller (13 lines) +├── DependencyInjection/ +│ └── UmbracoBuilderExtensions.cs # AddUmbraco(), AddUmbracoCore() (338 lines) +├── Extensions/ +│ ├── BlockGridTemplateExtensions.cs # Block grid Razor helpers +│ ├── BlockListTemplateExtensions.cs # Block list Razor helpers +│ ├── HttpRequestExtensions.cs # Request utility methods +│ ├── ImageCropperTemplateExtensions.cs # Image crop Razor helpers +│ ├── LinkGeneratorExtensions.cs # URL generation helpers +│ ├── WebApplicationExtensions.cs # BootUmbracoAsync() (32 lines) +│ └── [30+ extension classes] +├── Filters/ +│ ├── BackOfficeCultureFilter.cs # Culture detection +│ ├── DisableBrowserCacheAttribute.cs # Cache control headers +│ ├── UmbracoMemberAuthorizeAttribute.cs # Member authorization +│ └── ValidateUmbracoFormRouteStringAttribute.cs +├── Localization/ +│ ├── UmbracoBackOfficeIdentityCultureProvider.cs +│ └── UmbracoPublishedContentCultureProvider.cs +├── Middleware/ +│ ├── BootFailedMiddleware.cs # Startup failure handling (81 lines) +│ └── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines) +├── Routing/ +│ ├── IAreaRoutes.cs # Area routing interface +│ ├── IRoutableDocumentFilter.cs # Content routing filter +│ ├── UmbracoRouteValues.cs # Route data container (60 lines) +│ └── UmbracoVirtualPageRoute.cs # Virtual page routing +├── Security/ +│ ├── MemberSignInManager.cs # Member sign-in (350 lines) +│ ├── UmbracoSignInManager.cs # Base sign-in manager +│ ├── IMemberSignInManager.cs # Sign-in interface +│ └── EncryptionHelper.cs # Surface controller encryption +├── Templates/ +│ └── TemplateRenderer.cs # Razor template rendering +├── UmbracoContext/ +│ ├── UmbracoContext.cs # Request context (173 lines) +│ └── UmbracoContextFactory.cs # Context creation (75 lines) +├── UmbracoHelper.cs # Template helper (427 lines) +└── Umbraco.Web.Common.csproj # Project configuration (48 lines) +``` + +### Request Pipeline Flow + +``` +WebApplication.BootUmbracoAsync() + ↓ +UseUmbraco().WithMiddleware() + ↓ +┌──────────────────────────────┐ +│ UmbracoApplicationBuilder │ +│ ├─ RunPrePipeline() │ +│ ├─ BootFailedMiddleware │ +│ ├─ UseUmbracoCore() │ +│ ├─ UseStaticFiles() │ +│ ├─ UseRouting() │ +│ ├─ UseAuthentication() │ +│ ├─ UseAuthorization() │ +│ ├─ UseRequestLocalization() │ +│ └─ RunPostPipeline() │ +└──────────────────────────────┘ + ↓ +WithEndpoints() → Content routing +``` + +--- + +## 2. Commands + +**For Git workflow and build commands**, see [repository root](../../CLAUDE.md). + +--- + +## 3. Key Components + +### DI Entry Point (DependencyInjection/UmbracoBuilderExtensions.cs) + +**AddUmbraco()** (lines 77-123) - Creates `IUmbracoBuilder` and registers core services: +```csharp +public static IUmbracoBuilder AddUmbraco( + this IServiceCollection services, + IWebHostEnvironment webHostEnvironment, + IConfiguration config) +``` + +Key registrations: +- Sets `DataDirectory` for database paths (line 97-99) +- Creates `HttpContextAccessor` singleton (line 104-105) +- Initializes `AppCaches` with request-scoped cache +- Creates `TypeLoader` for assembly scanning +- Returns `UmbracoBuilder` for fluent configuration + +**AddUmbracoCore()** (lines 131-173) - Registers ASP.NET Core-specific services: +- `IHostingEnvironment` → `AspNetCoreHostingEnvironment` +- `IBackOfficeInfo` → `AspNetCoreBackOfficeInfo` +- `IDbProviderFactoryCreator` with SQL syntax providers +- Telemetry providers and application lifetime + +**AddWebComponents()** (lines 222-288) - Registers web-specific services: +- Session with `UMB_SESSION` cookie (line 229) +- API versioning configuration +- Password hasher, cookie manager, IP resolver +- `UmbracoHelper`, `UmbracoContextFactory` +- All middleware as singletons + +### Application Builder (ApplicationBuilder/UmbracoApplicationBuilder.cs) + +Orchestrates middleware registration in correct order. + +**WithMiddleware()** (lines 49-65): +1. `RunPrePipeline()` - Custom filters before Umbraco middleware +2. `RegisterDefaultRequiredMiddleware()` - Core middleware stack +3. `RunPostPipeline()` - Custom filters after +4. User-provided middleware callback + +**RegisterDefaultRequiredMiddleware()** (lines 70-104): +```csharp +UseUmbracoCoreMiddleware(); +AppBuilder.UseUmbracoMediaFileProvider(); +AppBuilder.UseUmbracoBackOfficeRewrites(); +AppBuilder.UseStaticFiles(); +AppBuilder.UseUmbracoPluginsStaticFiles(); +AppBuilder.UseRouting(); +AppBuilder.UseAuthentication(); +AppBuilder.UseAuthorization(); +AppBuilder.UseAntiforgery(); +AppBuilder.UseRequestLocalization(); +AppBuilder.UseSession(); +``` + +**Pipeline Filter Hooks** (IUmbracoPipelineFilter): +- `OnPrePipeline()` - Before any Umbraco middleware +- `OnPreRouting()` - Before UseRouting() +- `OnPostRouting()` - After UseRouting() +- `OnPostPipeline()` - After all Umbraco middleware +- `OnEndpoints()` - Before endpoint mapping + +### UmbracoContext (UmbracoContext/UmbracoContext.cs) + +Request-scoped container for Umbraco request state. + +**Key Properties**: +- `Content` → `IPublishedContentCache` (line 102) +- `Media` → `IPublishedMediaCache` (line 105) +- `Domains` → `IDomainCache` (line 108) +- `PublishedRequest` → Routed content for request (line 111) +- `InPreviewMode` → Preview cookie detected (lines 140-152) +- `OriginalRequestUrl` / `CleanedUmbracoUrl` - Request URLs + +**Preview Detection** (lines 154-166): +```csharp +private void DetectPreviewMode() +{ + // Check preview cookie, verify not backoffice request + var previewToken = _cookieManager.GetCookieValue(Constants.Web.PreviewCookieName); + _previewing = _previewToken.IsNullOrWhiteSpace() == false; +} +``` + +### UmbracoHelper (UmbracoHelper.cs) + +Template helper for Razor views (scoped lifetime). + +**Content Retrieval** (lines 192-317): +- `Content(id)` - Get by int, Guid, string, or Udi +- `Content(ids)` - Batch retrieval +- `ContentAtRoot()` - Root content items + +**Media Retrieval** (lines 320-424): +- Same pattern as content + +**Dictionary** (lines 102-189): +- `GetDictionaryValue(key)` - Localized string lookup +- `GetDictionaryValueOrDefault(key, defaultValue)` +- `CultureDictionary` - Current culture dictionary + +### Controller Base Classes + +| Controller | Purpose | Features | +|------------|---------|----------| +| `UmbracoController` | Base MVC controller | Simple base, debug InstanceId | +| `UmbracoAuthorizedController` | Backoffice controllers | `[Authorize(BackOfficeAccess)]`, `[DisableBrowserCache]` | +| `PluginController` | Plugin/package controllers | UmbracoContext, Services, AppCaches, ProfilingLogger | +| `UmbracoApiController` | Legacy API controller | **Obsolete** - Use ASP.NET Core ApiController | +| `IRenderController` | Frontend rendering marker | Route hijacking support | + +**PluginController** (lines 18-104): +- Provides `UmbracoContext`, `DatabaseFactory`, `Services`, `AppCaches` +- Static metadata caching with `ConcurrentDictionary` +- Auto-discovers `[PluginController]` and `[IsBackOffice]` attributes + +### Member Sign-In (Security/MemberSignInManager.cs) + +ASP.NET Core Identity sign-in manager for members. + +**Key Features**: +- External login with auto-linking (lines 112-142) +- Two-factor authentication support (lines 162-172) +- Auto-link and create member accounts (lines 180-267) + +**Auto-Link Flow** (lines 180-267): +1. Check if auto-link enabled and email available +2. Find or create member by email +3. Call `OnAutoLinking` callback for customization +4. Add external login link +5. Sign in or request 2FA + +**Result Types** (lines 320-348): +- `ExternalLoginSignInResult.NotAllowed` - Login refused by callback +- `AutoLinkSignInResult.FailedNoEmail` - No email from provider +- `AutoLinkSignInResult.FailedCreatingUser` - User creation failed +- `AutoLinkSignInResult.FailedLinkingUser` - Link creation failed + +### Middleware + +**BootFailedMiddleware** (lines 17-81): +- Intercepts requests when `RuntimeLevel == BootFailed` +- Debug mode: Rethrows exception for stack trace +- Production: Shows `BootFailed.html` error page + +**PreviewAuthenticationMiddleware** (lines 22-84): +- Adds backoffice identity to principal for preview requests +- Skips client-side requests and backoffice paths +- Uses `IPreviewService.TryGetPreviewClaimsIdentityAsync()` + +--- + +## 4. Routing + +### UmbracoRouteValues (Routing/UmbracoRouteValues.cs) + +Container for routed request data: +- `PublishedRequest` - The resolved content request +- `ControllerActionDescriptor` - MVC routing info +- `TemplateName` - Resolved template name +- `DefaultActionName` = "Index" + +### Route Hijacking + +Implement `IRenderController` to handle specific content types: +```csharp +public class ProductController : Controller, IRenderController +{ + public IActionResult Index() => View(); +} +``` + +### Virtual Pages + +Implement `IVirtualPageController` for URL-to-content mapping without physical content nodes. + +--- + +## 5. Project-Specific Notes + +### Warning Suppressions (csproj lines 10-22) + +Multiple analyzer warnings suppressed: +- SA1117, SA1401, SA1134 - StyleCop formatting +- ASP0019 - Header dictionary usage +- CS0618/SYSLIB0051 - Obsolete references +- IDE0040/SA1400 - Access modifiers +- SA1649 - File name matching + +### InternalsVisibleTo + +```xml +Umbraco.Tests.UnitTests +``` + +### Known Technical Debt + +1. **MVC Global State** (UmbracoBuilderExtensions.cs:210-211): `AddControllersWithViews` modifies global app, order matters +2. **OptionsMonitor Hack** (AspNetCore/OptionsMonitorAdapter.cs:6): Temporary workaround for TypeLoader during ConfigureServices +3. **DisposeResources TODO** (UmbracoContext.cs:168-171): Empty dispose method marked for removal +4. **Pipeline Default Implementations** (IUmbracoPipelineFilter.cs:36,45): Default methods to remove in Umbraco 13 +5. **SignIn Manager Sharing** (MemberSignInManager.cs:319,325): Could share code with backoffice sign-in + +### Session Configuration + +Default session cookie (lines 227-231): +```csharp +options.Cookie.Name = "UMB_SESSION"; +options.Cookie.HttpOnly = true; +``` + +Can be overridden by calling `AddSession` after `AddWebComponents`. + +--- + +## Quick Reference + +### Startup Flow + +```csharp +// Program.cs +builder.Services.AddUmbraco(webHostEnvironment, config) + .AddUmbracoCore() + .AddMvcAndRazor() + .AddWebComponents() + .AddUmbracoProfiler() + .Build(); + +await app.BootUmbracoAsync(); +app.UseUmbraco() + .WithMiddleware(u => u.UseBackOffice()) + .WithEndpoints(u => u.UseBackOfficeEndpoints()); +``` + +### Key Interfaces + +| Interface | Implementation | Purpose | +|-----------|----------------|---------| +| `IUmbracoContext` | UmbracoContext | Request state | +| `IUmbracoContextFactory` | UmbracoContextFactory | Context creation | +| `IUmbracoContextAccessor` | (in Core) | Context access | +| `IMemberSignInManager` | MemberSignInManager | Member auth | +| `IUmbracoApplicationBuilder` | UmbracoApplicationBuilder | Pipeline config | + +### Extension Method Namespaces + +Most extensions are in `Umbraco.Extensions` namespace: +- `HttpRequestExtensions` - Request helpers +- `LinkGeneratorExtensions` - URL generation +- `BlockGridTemplateExtensions` - Block grid rendering +- `ImageCropperTemplateExtensions` - Image cropping + +### Related Projects + +| Project | Relationship | +|---------|--------------| +| `Umbraco.Core` | Interface contracts | +| `Umbraco.Infrastructure` | Service implementations | +| `Umbraco.Examine.Lucene` | Search dependency | +| `Umbraco.PublishedCache.HybridCache` | Caching dependency | +| `Umbraco.Web.UI` | Main web application (references this) | +| `Umbraco.Cms.Api.Common` | API layer (references this) | diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs index 1ba9a5252627..39a5d7a30f51 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Controllers; @@ -58,21 +59,48 @@ public void Configure(CookieAuthenticationOptions options) await securityStampValidator.ValidateAsync(ctx); }, - OnRedirectToAccessDenied = ctx => + // retain the login redirect behavior in .NET 10 + // - see https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/10/cookie-authentication-api-endpoints + OnRedirectToLogin = context => { - // When the controller is an UmbracoAPIController, we want to return a StatusCode instead of a redirect. - // All other cases should use the default Redirect of the CookieAuthenticationEvent. - var controllerDescriptor = ctx.HttpContext.GetEndpoint()?.Metadata - .OfType() - .FirstOrDefault(); + if (IsXhr(context.Request)) + { + context.Response.Headers.Location = context.RedirectUri; + context.Response.StatusCode = 401; + } + else + { + context.Response.Redirect(context.RedirectUri); + } - if (!controllerDescriptor?.ControllerTypeInfo.IsSubclassOf(typeof(UmbracoApiController)) ?? false) + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = context => + { + // TODO: rewrite this to match OnRedirectToLogin (with a 403 status code) when UmbracoApiController is removed + // When the controller is an UmbracoAPIController, or if the request is an XHR, we want to return a + // StatusCode instead of a redirect. + // All other cases should use the default Redirect of the CookieAuthenticationEvent. + if (IsXhr(context.Request) is false && IsUmbracoApiControllerRequest(context.HttpContext) is false) { - new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx); + new CookieAuthenticationEvents().OnRedirectToAccessDenied(context); } return Task.CompletedTask; }, }; + return; + + bool IsUmbracoApiControllerRequest(HttpContext context) + => context.GetEndpoint() + ?.Metadata + .OfType() + .FirstOrDefault() + ?.ControllerTypeInfo + .IsSubclassOf(typeof(UmbracoApiController)) is true; + + bool IsXhr(HttpRequest request) => + string.Equals(request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) || + string.Equals(request.Headers.XRequestedWith, "XMLHttpRequest", StringComparison.Ordinal); } } diff --git a/src/Umbraco.Web.UI.Client/devops/icons/index.js b/src/Umbraco.Web.UI.Client/devops/icons/index.js index ecf10d0a773a..48c98f9af4fd 100644 --- a/src/Umbraco.Web.UI.Client/devops/icons/index.js +++ b/src/Umbraco.Web.UI.Client/devops/icons/index.js @@ -15,6 +15,7 @@ const iconMapJson = `${moduleDirectory}/icon-dictionary.json`; const lucideSvgDirectory = 'node_modules/lucide-static/icons'; const simpleIconsSvgDirectory = 'node_modules/simple-icons/icons'; +const customSvgDirectory = `${moduleDirectory}/svgs/custom`; const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true'; @@ -124,11 +125,39 @@ const collectDictionaryIcons = async () => { } }); + // Custom: + if (fileJSON['custom']) { + fileJSON['custom'].forEach((iconDef) => { + if (iconDef.file && iconDef.name) { + const path = customSvgDirectory + '/' + iconDef.file; + + try { + const rawData = readFileSync(path); + const svg = rawData.toString(); + const iconFileName = iconDef.name; + + const icon = { + name: iconDef.name, + legacy: iconDef.legacy, + fileName: iconFileName, + svg, + output: `${iconsOutputDirectory}/${iconFileName}.ts`, + }; + + icons.push(icon); + } catch { + errors.push(`[Custom] Could not load file: '${path}'`); + console.log(`[Custom] Could not load file: '${path}'`); + } + } + }); + } + return icons; }; const collectDiskIcons = async (icons) => { - const iconPaths = await glob(`${umbracoSvgDirectory}/icon-*.svg`); + const iconPaths = await glob(`${umbracoSvgDirectory}/legacy/icon-*.svg`); iconPaths.forEach((path) => { const rawData = readFileSync(path); diff --git a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts b/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts index 495a2bcbe99b..945c25507a11 100644 --- a/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts +++ b/src/Umbraco.Web.UI.Client/examples/collection/collection/table-view/manifests.ts @@ -10,7 +10,7 @@ export const manifests: Array = [ weight: 100, meta: { label: 'Table', - icon: 'icon-list', + icon: 'icon-table', pathName: 'table', }, conditions: [ diff --git a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-alias-condition-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-alias-condition-workspace-view.element.ts new file mode 100644 index 000000000000..0df9c4c0082d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-alias-condition-workspace-view.element.ts @@ -0,0 +1,48 @@ +import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; +import { html, customElement, state, css } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +@customElement('example-content-type-alias-condition-workspace-view') +export class ExampleContentTypeAliasConditionWorkspaceViewElement extends UmbLitElement { + @state() + private _contentTypeAliases: string[] = []; + + constructor() { + super(); + + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe(context?.structure.contentTypeAliases, (contentTypeAliases) => { + this._contentTypeAliases = contentTypeAliases || []; + }); + }); + } + + override render() { + return html` +

Content Type Alias Condition Example

+

+ Content Type ${this._contentTypeAliases.length > 1 ? 'aliases' : 'alias'}: + ${this._contentTypeAliases} +

+
`; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + margin: var(--uui-size-layout-2); + } + `, + ]; +} + +export { ExampleContentTypeAliasConditionWorkspaceViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'example-content-type-alias-condition-workspace-view': ExampleContentTypeAliasConditionWorkspaceViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-unique-condition-workspace-view.element.ts b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-unique-condition-workspace-view.element.ts new file mode 100644 index 000000000000..6ce9bc9eec03 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/content-type-unique-condition-workspace-view.element.ts @@ -0,0 +1,48 @@ +import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content'; +import { html, customElement, state, css } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +@customElement('example-content-type-unique-condition-workspace-view') +export class ExampleContentTypeUniqueConditionWorkspaceViewElement extends UmbLitElement { + @state() + private _contentTypeUniques: string[] = []; + + constructor() { + super(); + + this.consumeContext(UMB_CONTENT_WORKSPACE_CONTEXT, (context) => { + this.observe(context?.structure.contentTypeUniques, (contentTypeUniques) => { + this._contentTypeUniques = contentTypeUniques || []; + }); + }); + } + + override render() { + return html` +

Content Type Unique Condition Example

+

+ Content Type ${this._contentTypeUniques.length > 1 ? 'ids' : 'id'}: + ${this._contentTypeUniques} +

+
`; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: block; + margin: var(--uui-size-layout-2); + } + `, + ]; +} + +export { ExampleContentTypeUniqueConditionWorkspaceViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'example-content-type-unique-condition-workspace-view': ExampleContentTypeUniqueConditionWorkspaceViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts index 43cc3a71a366..f4322158ff99 100644 --- a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts +++ b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts @@ -1,22 +1,43 @@ -import { UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS } from '@umbraco-cms/backoffice/content-type'; +import { + UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS, + UMB_WORKSPACE_CONTENT_TYPE_UNIQUE_CONDITION, +} from '@umbraco-cms/backoffice/content-type'; -const workspace: UmbExtensionManifest = { +const workspaceViewAlias: UmbExtensionManifest = { type: 'workspaceView', - alias: 'Example.WorkspaceView.EntityContentTypeCondition', - name: 'Example Workspace View With Entity Content Type Condition', - element: () => import('./workspace-view.element.js'), + alias: 'Example.WorkspaceView.EntityContentTypeAliasCondition', + name: 'Example Workspace View With Entity Content Type Alias Condition', + element: () => import('./content-type-alias-condition-workspace-view.element.js'), meta: { icon: 'icon-bus', - label: 'Conditional', - pathname: 'conditional', + label: 'Conditional (Alias)', + pathname: 'conditional-alias', }, conditions: [ { alias: UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS, - //match : 'blogPost' + //match: 'blogPost', oneOf: ['blogPost', 'mediaType1'], }, ], }; -export const manifests = [workspace]; +const workspaceViewUnique: UmbExtensionManifest = { + type: 'workspaceView', + alias: 'Example.WorkspaceView.EntityContentTypeUniqueCondition', + name: 'Example Workspace View With Content Type Unique Condition', + element: () => import('./content-type-unique-condition-workspace-view.element.js'), + meta: { + icon: 'icon-science', + label: 'Conditional (Unique)', + pathname: 'conditional-unique', + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONTENT_TYPE_UNIQUE_CONDITION, + oneOf: ['721e85d3-0a2d-4f99-be55-61a5c5ed5c14', '1b88975d-60d0-4b84-809a-4a4deff38a66'], // Example uniques + }, + ], +}; + +export const manifests = [workspaceViewAlias, workspaceViewUnique]; diff --git a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/workspace-view.element.ts b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/workspace-view.element.ts deleted file mode 100644 index b4390f0f625f..000000000000 --- a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/workspace-view.element.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { html, customElement } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; - -@customElement('umb-example-entity-content-type-condition') -export class UmbWorkspaceExampleViewElement extends UmbLitElement { - override render() { - return html`

- This is a conditional element that is only shown in workspaces based on it's entities content type. -

`; - } -} - -export default UmbWorkspaceExampleViewElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-example-entity-content-type-condition': UmbWorkspaceExampleViewElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts index bfd25e2dfcdd..03569d6b915d 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts @@ -1,11 +1,9 @@ import { UMB_BACKOFFICE_CONTEXT } from '../backoffice.context.js'; -import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; -import { isCurrentUserAnAdmin } from '@umbraco-cms/backoffice/current-user'; +import { css, html, customElement, state, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; -import { UMB_NEWVERSION_MODAL, UMB_SYSINFO_MODAL } from '@umbraco-cms/backoffice/sysinfo'; -import type { UmbServerUpgradeCheck } from '@umbraco-cms/backoffice/sysinfo'; +import { UMB_SYSINFO_MODAL } from '@umbraco-cms/backoffice/sysinfo'; /** * The backoffice header logo element. @@ -22,14 +20,6 @@ export class UmbBackofficeHeaderLogoElement extends UmbLitElement { @state() private _version?: string; - @state() - private _isUserAdmin = false; - - @state() - private _serverUpgradeCheck: UmbServerUpgradeCheck | null = null; - - #backofficeContext?: typeof UMB_BACKOFFICE_CONTEXT.TYPE; - constructor() { super(); @@ -42,23 +32,9 @@ export class UmbBackofficeHeaderLogoElement extends UmbLitElement { }, '_observeVersion', ); - - this.#backofficeContext = context; }); } - protected override firstUpdated() { - this.#isAdmin(); - } - - async #isAdmin() { - this._isUserAdmin = await isCurrentUserAnAdmin(this); - - if (this._isUserAdmin) { - this._serverUpgradeCheck = this.#backofficeContext ? await this.#backofficeContext.serverUpgradeCheck() : null; - } - } - override render() { return html`