Files
tiku-backend.net/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs

234 lines
9.2 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Tiku.Application.Tenancy;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Tenancy;
public sealed class TenantFrontendConfigService(
TikuDbContext dbContext,
IMemoryCache cache,
IFeatureAccessService featureAccessService) : ITenantFrontendConfigService
{
private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2);
public async Task<TenantFrontendConfigItem> 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<TenantFrontendConfigItem> 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<TenantFrontendConfigItem> 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<TenantRuntimeBootstrap> GetRuntimeAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
if (cache.TryGetValue<TenantRuntimeBootstrap>(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 now = DateTimeOffset.UtcNow;
var hasActiveSubscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(item =>
item.TenantId == tenantId &&
(item.Status == TenantSaasSubscriptionStatus.Trial || item.Status == TenantSaasSubscriptionStatus.Active) &&
item.StartsAt <= now && item.CurrentPeriodEnd > now,
cancellationToken);
if (!hasActiveSubscription)
{
throw new TenantFrontendConfigException("subscription_inactive", "An active tenant subscription is required.");
}
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(),
(await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, cancellationToken))
.Where(code => code != SaasFeatureCatalog.CoreBackoffice)
.Order(StringComparer.Ordinal)
.ToArray(),
(await dbContext.TenantAuthPolicies.AsNoTracking()
.Where(item => item.TenantId == tenantId)
.Select(item => item.AllowedStudentLoginMethods)
.SingleOrDefaultAsync(cancellationToken) ?? ["password"])
.Select(value => value.Trim().ToLowerInvariant())
.Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToArray());
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("<script", StringComparison.OrdinalIgnoreCase) ||
text.Contains("javascript:", StringComparison.OrdinalIgnoreCase))
{
throw UnsafeConfig();
}
}
}
private static TenantFrontendConfigException UnsafeConfig() => new(
"frontend_config_unsafe",
"Frontend configuration cannot contain HTML or executable scripts.");
private static string CacheKey(Guid tenantId) => $"tenant-runtime:{tenantId:N}";
}