forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
207
Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
Normal file
207
Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
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<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 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("<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}";
|
||||
}
|
||||
Reference in New Issue
Block a user