using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Serilog; using Tiku.Application; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Application.Storage; using Tiku.Application.Tenancy; using Tiku.Infrastructure; using Tiku.Infrastructure.Caching; using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Observability; using Tiku.Infrastructure.Security; using Tiku.Infrastructure.Storage; namespace Tiku.Worker; internal static class WorkerDependencyInjection { internal static HostApplicationBuilder AddWorkerServices(this HostApplicationBuilder builder) { builder.Services.AddSerilog((services, configuration) => configuration .ReadFrom.Configuration(builder.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext(), true); var connectionString = builder.Configuration.GetConnectionString("Database") ?? builder.Configuration["DATABASE_URL"] ?? (builder.Environment.IsDevelopment() ? $"Host=localhost;Database=tiku;Username={Environment.UserName}" : throw new InvalidOperationException( "Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL.")); builder.Services.AddApplication(); builder.Services.AddAuthentication(); builder.Services.AddDataProtection(); builder.Services.AddInfrastructure(connectionString); var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(AuthorizationCacheOptions.SectionName)); if (!string.IsNullOrWhiteSpace(redisConnectionString)) builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName); else if (builder.Environment.IsProduction()) throw new InvalidOperationException( "Redis is required in Production for authorization cache invalidation retries."); builder.Services.AddBusinessCaching( builder.Environment.EnvironmentName, !string.IsNullOrWhiteSpace(redisConnectionString)); var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"]; var telemetry = builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing.AddFusionCacheInstrumentation()) .WithMetrics(metrics => metrics .AddFusionCacheInstrumentation() .AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName)); if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint)) telemetry .WithTracing(tracing => tracing.AddOtlpExporter(options => options.Endpoint = endpoint)) .WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint)); builder.Services.Configure( builder.Configuration.GetSection(ObjectStorageOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(AliyunOssOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(S3CompatibleOptions.SectionName)); builder.Services.PostConfigure(options => { options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider; if (!builder.Environment.IsProduction() && options.DefaultProvider == ObjectStorageProviders.AliyunOss && !HasConfiguredAliyunOss(builder.Configuration)) options.DefaultProvider = ObjectStorageProviders.LocalDev; options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket; options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl; options.AllowedMimePrefixes = SplitLegacyList( builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"], options.AllowedMimePrefixes); options.AllowedMimeTypes = SplitLegacyList( builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"], options.AllowedMimeTypes); options.RequireTenantPrefix = bool.TryParse( builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"], out var requireTenantPrefix) ? requireTenantPrefix : options.RequireTenantPrefix; options.MaxUploadBytes = long.TryParse( builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"], out var maxUploadBytes) ? maxUploadBytes : options.MaxUploadBytes; }); builder.Services.PostConfigure(options => { options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region; options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint; options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId; options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret; options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken; options.UseInternalEndpoint = bool.TryParse( builder.Configuration["ALIYUN_OSS_INTERNAL"], out var useInternalEndpoint) ? useInternalEndpoint : options.UseInternalEndpoint; }); builder.Services.PostConfigure(options => { options.Endpoint = builder.Configuration["S3_ENDPOINT"] ?? options.Endpoint; options.AccessKey = builder.Configuration["S3_ACCESS_KEY"] ?? options.AccessKey; options.SecretKey = builder.Configuration["S3_SECRET_KEY"] ?? options.SecretKey; options.Region = builder.Configuration["S3_REGION"] ?? options.Region; options.Secure = bool.TryParse(builder.Configuration["S3_SECURE"], out var secure) ? secure : options.Secure; }); builder.Services.AddOptions() .Validate( options => !builder.Environment.IsProduction() || options.DefaultProvider == ObjectStorageProviders.AliyunOss, "Production managed storage must use the configured Aliyun OSS provider.") .ValidateOnStart(); builder.Services.AddOptions() .Validate>( (aliyun, storage) => !builder.Environment.IsProduction() || storage.Value.DefaultProvider != ObjectStorageProviders.AliyunOss || aliyun.IsConfigured, "Aliyun OSS credentials and region or endpoint are required when it is the default provider.") .ValidateOnStart(); builder.Services.AddOptions() .Validate>( (s3, storage) => storage.Value.DefaultProvider != ObjectStorageProviders.LocalDev || s3.IsConfigured, "S3-compatible endpoint and credentials are required when local_dev is the default provider.") .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName)) .Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.") .Validate>( (clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes, "ClamAV StreamMaxLength must cover the storage max upload size.") .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(WorkerOptions.SectionName)) .Validate(WorkerOptions.BeValid, "Worker settings are invalid.") .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("TenantDomains")); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("SaasSubscriptions")); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("FeatureUsageReconciliation")); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("CommercialBilling")) .Validate(options => options.BatchSize is >= 1 and <= 1000, "Commercial billing batch size is invalid.") .ValidateOnStart(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); return builder; } private static string[] SplitLegacyList(string? value, string[] fallback) { return string.IsNullOrWhiteSpace(value) ? fallback : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); } private static bool HasConfiguredAliyunOss(IConfiguration configuration) { return !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? configuration["Storage:AliyunOss:AccessKeyId"]) && !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? configuration["Storage:AliyunOss:AccessKeySecret"]) && (!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_REGION"] ?? configuration["Storage:AliyunOss:Region"]) || !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ENDPOINT"] ?? configuration["Storage:AliyunOss:Endpoint"])); } }