using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Tiku.Application.Tenancy; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; public sealed class TenantFrontendConfigService( TikuDbContext dbContext, IMemoryCache cache) : ITenantFrontendConfigService { private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2); public async Task GetAsync( Guid tenantId, CancellationToken cancellationToken = default) { var config = await dbContext.TenantFrontendConfigs.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); return ToItem(config ?? CreateDefault(tenantId)); } public async Task SaveDraftAsync( Guid tenantId, TenantFrontendConfigDraft draft, CancellationToken cancellationToken = default) { Validate(draft); var config = await dbContext.TenantFrontendConfigs .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (config is null) { config = CreateDefault(tenantId); dbContext.TenantFrontendConfigs.Add(config); } config.DraftBranding = draft.Branding.Clone(); config.DraftTheme = draft.Theme.Clone(); config.DraftFeatures = draft.Features.Clone(); config.DraftNavigation = draft.Navigation.Clone(); config.DraftHomeModules = draft.HomeModules.Clone(); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(config); } public async Task PublishAsync( Guid tenantId, int expectedVersion, CancellationToken cancellationToken = default) { var config = await dbContext.TenantFrontendConfigs .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) ?? throw new TenantFrontendConfigException( "frontend_config_not_found", "Save a frontend configuration draft before publishing."); if (config.ConfigVersion != expectedVersion) { throw new TenantFrontendConfigException( "frontend_config_version_conflict", "Frontend configuration has changed; reload it before publishing."); } var draft = Draft(config); Validate(draft); config.PublishedBranding = config.DraftBranding.Clone(); config.PublishedTheme = config.DraftTheme.Clone(); config.PublishedFeatures = config.DraftFeatures.Clone(); config.PublishedNavigation = config.DraftNavigation.Clone(); config.PublishedHomeModules = config.DraftHomeModules.Clone(); config.ConfigVersion++; config.PublishedAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); cache.Remove(CacheKey(tenantId)); return ToItem(config); } public async Task GetRuntimeAsync( Guid tenantId, CancellationToken cancellationToken = default) { if (cache.TryGetValue(CacheKey(tenantId), out var cached) && cached is not null) { return cached; } var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync( item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) ?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found."); var config = await dbContext.TenantFrontendConfigs.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken) ?? CreateDefault(tenantId); var result = new TenantRuntimeBootstrap( config.SchemaVersion, config.ConfigVersion, tenant.Slug, tenant.Name, config.PublishedBranding.Clone(), config.PublishedTheme.Clone(), config.PublishedFeatures.Clone(), config.PublishedNavigation.Clone(), config.PublishedHomeModules.Clone()); cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration); return result; } private static TenantFrontendConfig CreateDefault(Guid tenantId) { return new TenantFrontendConfig { TenantId = tenantId, SchemaVersion = 1, ConfigVersion = 1 }; } private static TenantFrontendConfigItem ToItem(TenantFrontendConfig config) { return new TenantFrontendConfigItem( config.SchemaVersion, config.ConfigVersion, new TenantFrontendConfigDraft( config.PublishedBranding.Clone(), config.PublishedTheme.Clone(), config.PublishedFeatures.Clone(), config.PublishedNavigation.Clone(), config.PublishedHomeModules.Clone()), Draft(config), config.PublishedAt); } private static TenantFrontendConfigDraft Draft(TenantFrontendConfig config) { return new TenantFrontendConfigDraft( config.DraftBranding.Clone(), config.DraftTheme.Clone(), config.DraftFeatures.Clone(), config.DraftNavigation.Clone(), config.DraftHomeModules.Clone()); } private static void Validate(TenantFrontendConfigDraft draft) { RequireKind(draft.Branding, JsonValueKind.Object, "branding"); RequireKind(draft.Theme, JsonValueKind.Object, "theme"); RequireKind(draft.Features, JsonValueKind.Object, "features"); RequireKind(draft.Navigation, JsonValueKind.Array, "navigation"); RequireKind(draft.HomeModules, JsonValueKind.Array, "homeModules"); foreach (var root in new[] { draft.Branding, draft.Theme, draft.Features, draft.Navigation, draft.HomeModules }) { RejectUnsafeContent(root); } } private static void RequireKind(JsonElement value, JsonValueKind expected, string field) { if (value.ValueKind != expected) { throw new TenantFrontendConfigException( "frontend_config_invalid", $"Frontend configuration field '{field}' must be a JSON {expected.ToString().ToLowerInvariant()}."); } } private static void RejectUnsafeContent(JsonElement value) { if (value.ValueKind == JsonValueKind.Object) { foreach (var property in value.EnumerateObject()) { if (property.Name.Contains("script", StringComparison.OrdinalIgnoreCase) || property.Name.Contains("html", StringComparison.OrdinalIgnoreCase)) { throw UnsafeConfig(); } RejectUnsafeContent(property.Value); } } else if (value.ValueKind == JsonValueKind.Array) { foreach (var item in value.EnumerateArray()) { RejectUnsafeContent(item); } } else if (value.ValueKind == JsonValueKind.String) { var text = value.GetString() ?? string.Empty; if (text.Contains(" new( "frontend_config_unsafe", "Frontend configuration cannot contain HTML or executable scripts."); private static string CacheKey(Guid tenantId) => $"tenant-runtime:{tenantId:N}"; }