All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 11s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 11m22s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 1m42s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
833 lines
31 KiB
C#
833 lines
31 KiB
C#
using BCards.Web.Configuration;
|
|
using BCards.Web.Services;
|
|
using BCards.Web.Repositories;
|
|
using BCards.Web.HealthChecks;
|
|
using AspNetCore.DataProtection.MongoDb;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.Google;
|
|
using Microsoft.AspNetCore.Localization;
|
|
using Microsoft.Extensions.Options;
|
|
using MongoDB.Driver;
|
|
using System.Globalization;
|
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
|
using SendGrid;
|
|
using BCards.Web.Middleware;
|
|
using Microsoft.AspNetCore.Http.Features;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Serilog.Sinks.OpenSearch;
|
|
#if TESTING
|
|
using BCards.Web.TestSupport;
|
|
#endif
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var isDevelopment = builder.Environment.IsDevelopment();
|
|
var hostname = Environment.MachineName;
|
|
|
|
Serilog.Debugging.SelfLog.Enable(msg =>
|
|
{
|
|
Console.WriteLine($"[SERILOG SELF] {DateTime.Now:HH:mm:ss} {msg}");
|
|
System.Diagnostics.Debug.WriteLine($"[SERILOG SELF] {msg}");
|
|
});
|
|
|
|
var loggerConfig = new LoggerConfiguration()
|
|
.ReadFrom.Configuration(builder.Configuration)
|
|
.Enrich.FromLogContext()
|
|
.Enrich.WithEnvironmentName()
|
|
.Enrich.WithProcessId()
|
|
.Enrich.WithThreadId()
|
|
.Enrich.WithProperty("ApplicationName", builder.Configuration["ApplicationName"] ?? "BCards")
|
|
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
|
|
.Enrich.WithProperty("Hostname", hostname);
|
|
|
|
if (isDevelopment)
|
|
{
|
|
loggerConfig
|
|
.MinimumLevel.Debug()
|
|
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Information)
|
|
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Information)
|
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
.MinimumLevel.Override("System", LogEventLevel.Warning)
|
|
// Console sempre ativo - logs garantidos
|
|
.WriteTo.Console(
|
|
restrictedToMinimumLevel: LogEventLevel.Debug,
|
|
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
|
// Arquivo local com rotação para Docker logs não ficarem enormes
|
|
.WriteTo.File(
|
|
"./logs/bcards-dev-.log",
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 2, // Só 2 dias como você quer
|
|
restrictedToMinimumLevel: LogEventLevel.Debug,
|
|
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}");
|
|
|
|
var openSearchUrl = builder.Configuration["Serilog:OpenSearchUrl"];
|
|
if (!string.IsNullOrEmpty(openSearchUrl))
|
|
{
|
|
var indexFormat = "b-cards-dev-{0:yyyy-MM}";
|
|
|
|
try
|
|
{
|
|
// OpenSearch configurado para ser MUITO agressivo no envio
|
|
loggerConfig.WriteTo.Async(a => a.OpenSearch(new OpenSearchSinkOptions(new Uri(openSearchUrl))
|
|
{
|
|
IndexFormat = indexFormat,
|
|
AutoRegisterTemplate = true,
|
|
BufferBaseFilename = "./logs/opensearch-buffer", // Buffer em disco
|
|
ModifyConnectionSettings = conn => conn
|
|
.RequestTimeout(TimeSpan.FromSeconds(8))
|
|
.PingTimeout(TimeSpan.FromSeconds(4)),
|
|
MinimumLogEventLevel = LogEventLevel.Debug,
|
|
EmitEventFailure = EmitEventFailureHandling.WriteToSelfLog,
|
|
RegisterTemplateFailure = RegisterTemplateRecovery.IndexAnyway,
|
|
BatchPostingLimit = 10, // Lotes pequenos = envio mais frequente
|
|
Period = TimeSpan.FromSeconds(2), // Envia a cada 2 segundos
|
|
// Configurações para máxima persistência
|
|
BufferRetainedInvalidPayloadsLimitBytes = 100 * 1024 * 1024, // 100MB buffer
|
|
BufferLogShippingInterval = TimeSpan.FromSeconds(1), // Tenta reenviar rapidamente
|
|
TemplateCustomSettings = new Dictionary<string, string>
|
|
{
|
|
{"number_of_shards", "1"},
|
|
{"number_of_replicas", "0"}
|
|
}
|
|
}),
|
|
bufferSize: 10000, // Buffer grande na memória
|
|
blockWhenFull: false); // Nunca bloquear aplicação
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Falha silenciosa - logs continuam no console e arquivo
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// PRODUÇÃO permanece igual, mas também com fallback garantido
|
|
loggerConfig
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting.Diagnostics", LogEventLevel.Warning)
|
|
.MinimumLevel.Override("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogEventLevel.Warning)
|
|
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", LogEventLevel.Warning)
|
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
.MinimumLevel.Override("System", LogEventLevel.Warning)
|
|
|
|
.WriteTo.Console(
|
|
restrictedToMinimumLevel: LogEventLevel.Information,
|
|
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
|
.WriteTo.File(
|
|
"/app/logs/bcards-.log",
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 7,
|
|
restrictedToMinimumLevel: LogEventLevel.Warning,
|
|
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [{Hostname}] {Message:lj} {Properties:j}{NewLine}{Exception}");
|
|
|
|
var openSearchUrl = builder.Configuration["Serilog:OpenSearchUrl"];
|
|
if (!string.IsNullOrEmpty(openSearchUrl))
|
|
{
|
|
var environment = builder.Environment.EnvironmentName.ToLower();
|
|
var envMapping = environment switch
|
|
{
|
|
"production" => "prod",
|
|
"staging" => "release",
|
|
"development" => "dev",
|
|
_ => environment
|
|
};
|
|
|
|
var indexFormat = $"b-cards-{envMapping}-{{0:yyyy-MM}}";
|
|
|
|
try
|
|
{
|
|
loggerConfig.WriteTo.Async(a => a.OpenSearch(new OpenSearchSinkOptions(new Uri(openSearchUrl))
|
|
{
|
|
IndexFormat = indexFormat,
|
|
AutoRegisterTemplate = false,
|
|
BufferBaseFilename = "./logs/buffer",
|
|
ModifyConnectionSettings = conn => conn
|
|
.RequestTimeout(TimeSpan.FromSeconds(30))
|
|
.PingTimeout(TimeSpan.FromSeconds(10)),
|
|
MinimumLogEventLevel = LogEventLevel.Information,
|
|
EmitEventFailure = EmitEventFailureHandling.WriteToSelfLog,
|
|
RegisterTemplateFailure = RegisterTemplateRecovery.IndexAnyway,
|
|
BatchPostingLimit = 50,
|
|
Period = TimeSpan.FromSeconds(5),
|
|
}), bufferSize: 10000, blockWhenFull: false);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Falha silenciosa em produção - logs continuam no console/arquivo
|
|
}
|
|
}
|
|
}
|
|
|
|
var logger = loggerConfig.CreateLogger();
|
|
Log.Logger = logger;
|
|
|
|
Console.WriteLine($"[STARTUP] {DateTime.Now:HH:mm:ss} - Logger configurado");
|
|
Log.Information("=== APLICAÇÃO INICIANDO ===");
|
|
Log.Information("BCards iniciando em {Environment} no host {Hostname}",
|
|
builder.Environment.EnvironmentName, hostname);
|
|
|
|
// Use Serilog for the host
|
|
builder.Host.UseSerilog();
|
|
|
|
// Log startup information
|
|
Log.Information("Starting BCards application on {Hostname} in {Environment} mode", hostname, builder.Environment.EnvironmentName);
|
|
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
options.RequireHeaderSymmetry = false;
|
|
options.KnownNetworks.Clear();
|
|
options.KnownProxies.Clear();
|
|
options.ForwardLimit = null;
|
|
});
|
|
|
|
builder.Services.AddResponseCompression(options =>
|
|
{
|
|
options.EnableForHttps = true;
|
|
options.Providers.Add<Microsoft.AspNetCore.ResponseCompression.BrotliCompressionProvider>();
|
|
options.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
|
|
options.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes.Concat(
|
|
new[] { "application/javascript", "application/json", "application/xml", "text/css", "text/plain", "image/svg+xml" });
|
|
});
|
|
|
|
builder.Services.Configure<Microsoft.AspNetCore.ResponseCompression.BrotliCompressionProviderOptions>(options =>
|
|
{
|
|
options.Level = System.IO.Compression.CompressionLevel.Optimal;
|
|
});
|
|
|
|
builder.Services.Configure<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProviderOptions>(options =>
|
|
{
|
|
options.Level = System.IO.Compression.CompressionLevel.Optimal;
|
|
});
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddControllersWithViews()
|
|
.AddRazorRuntimeCompilation()
|
|
.AddViewLocalization()
|
|
.AddDataAnnotationsLocalization();
|
|
|
|
// MongoDB Configuration
|
|
builder.Services.Configure<MongoDbSettings>(
|
|
builder.Configuration.GetSection("MongoDb"));
|
|
|
|
builder.Services.AddSingleton<IMongoClient>(serviceProvider =>
|
|
{
|
|
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
|
|
var connectionString = settings.ConnectionString;
|
|
|
|
if (!connectionString.Contains("maxPoolSize"))
|
|
{
|
|
var separator = connectionString.Contains("?") ? "&" : "?";
|
|
connectionString += $"{separator}maxPoolSize=200&minPoolSize=20&maxIdleTimeMS=300000&socketTimeoutMS=60000&connectTimeoutMS=30000";
|
|
}
|
|
|
|
Log.Information("Connecting to MongoDB with optimized pool settings.");
|
|
return new MongoClient(connectionString);
|
|
});
|
|
|
|
builder.Services.AddScoped(serviceProvider =>
|
|
{
|
|
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
|
var settings = serviceProvider.GetRequiredService<IOptions<MongoDbSettings>>().Value;
|
|
return client.GetDatabase(settings.DatabaseName);
|
|
});
|
|
|
|
var dataProtectionSection = builder.Configuration.GetSection("DataProtection:Mongo");
|
|
var dataProtectionConnectionString = dataProtectionSection.GetValue<string>("ConnectionString")
|
|
?? builder.Configuration.GetSection("MongoDb").GetValue<string>("ConnectionString");
|
|
var dataProtectionDatabase = dataProtectionSection.GetValue<string>("DatabaseName")
|
|
?? builder.Configuration.GetSection("MongoDb").GetValue<string>("DatabaseName")
|
|
?? "BCardsDB";
|
|
var dataProtectionCollection = dataProtectionSection.GetValue<string>("CollectionName") ?? "DataProtectionKeys";
|
|
|
|
if (!string.IsNullOrWhiteSpace(dataProtectionConnectionString))
|
|
{
|
|
Log.Information("Configuring DataProtection to persist keys in MongoDB database {Database} / collection {Collection}",
|
|
dataProtectionDatabase, dataProtectionCollection);
|
|
|
|
builder.Services.AddDataProtection()
|
|
.SetApplicationName("BCards")
|
|
.PersistKeysToMongoDb(
|
|
() => new MongoClient(dataProtectionConnectionString).GetDatabase(dataProtectionDatabase),
|
|
dataProtectionCollection);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("DataProtection MongoDB configuration missing; encryption keys will be ephemeral per container.");
|
|
}
|
|
|
|
// Stripe Configuration with validation
|
|
builder.Services.Configure<StripeSettings>(
|
|
builder.Configuration.GetSection("Stripe"));
|
|
|
|
var stripeSettings = builder.Configuration.GetSection("Stripe").Get<StripeSettings>();
|
|
if (stripeSettings == null || string.IsNullOrEmpty(stripeSettings.SecretKey))
|
|
{
|
|
Log.Fatal("❌ STRIPE CONFIGURATION MISSING! Check your appsettings.json or environment variables.");
|
|
throw new InvalidOperationException("Stripe configuration is required");
|
|
}
|
|
|
|
Log.Information("🔧 Stripe Environment: {Environment} | Test Mode: {IsTestMode}",
|
|
stripeSettings.Environment.ToUpper(), stripeSettings.IsTestMode);
|
|
|
|
if (stripeSettings.IsTestMode)
|
|
{
|
|
Log.Warning("⚠️ STRIPE TEST MODE ENABLED - Only test payments will work");
|
|
}
|
|
else
|
|
{
|
|
Log.Information("💰 STRIPE LIVE MODE ENABLED - Real payments active");
|
|
}
|
|
|
|
// OAuth Configuration
|
|
builder.Services.Configure<GoogleAuthSettings>(
|
|
builder.Configuration.GetSection("Authentication:Google"));
|
|
|
|
builder.Services.Configure<MicrosoftAuthSettings>(
|
|
builder.Configuration.GetSection("Authentication:Microsoft"));
|
|
|
|
builder.Services.Configure<ModerationSettings>(
|
|
builder.Configuration.GetSection("Moderation"));
|
|
|
|
// Authentication
|
|
var authBuilder = builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
// DefaultChallengeScheme will be set conditionally below
|
|
});
|
|
|
|
authBuilder.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/Auth/Login";
|
|
options.LogoutPath = "/Auth/Logout";
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(7); // 7 dias em vez de 8 horas
|
|
options.SlidingExpiration = true;
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.IsEssential = true;
|
|
options.Cookie.SameSite = SameSiteMode.None; // Para Cloudflare
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
});
|
|
|
|
// Always register Google and Microsoft authentication schemes
|
|
authBuilder.AddGoogle(options =>
|
|
{
|
|
var googleAuth = builder.Configuration.GetSection("Authentication:Google");
|
|
options.ClientId = googleAuth["ClientId"] ?? "";
|
|
options.ClientSecret = googleAuth["ClientSecret"] ?? "";
|
|
options.CallbackPath = "/signin-google";
|
|
options.BackchannelTimeout = TimeSpan.FromSeconds(30);
|
|
options.SaveTokens = true;
|
|
options.UsePkce = true;
|
|
|
|
options.Events = new OAuthEvents
|
|
{
|
|
OnRedirectToAuthorizationEndpoint = context =>
|
|
{
|
|
// Fix para Cloudflare - remover porta 443 explícita
|
|
if (!builder.Environment.IsDevelopment())
|
|
{
|
|
context.RedirectUri = context.RedirectUri.Replace(":443", "");
|
|
}
|
|
context.Response.Redirect(context.RedirectUri);
|
|
return Task.CompletedTask;
|
|
},
|
|
OnRemoteFailure = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
logger.LogError("🔴 Google OAuth falhou: {Failure}", context.Failure?.Message);
|
|
logger.LogError("🔴 User Agent: {UserAgent}", context.Request.Headers.UserAgent.ToString());
|
|
|
|
context.Response.Redirect("/Auth/Login?error=google_oauth_failed");
|
|
context.HandleResponse();
|
|
return Task.CompletedTask;
|
|
},
|
|
OnTicketReceived = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
logger.LogInformation("✅ Google OAuth ticket recebido com sucesso");
|
|
return Task.CompletedTask;
|
|
}
|
|
};
|
|
})
|
|
.AddMicrosoftAccount(options =>
|
|
{
|
|
var msAuth = builder.Configuration.GetSection("Authentication:Microsoft");
|
|
options.ClientId = msAuth["ClientId"] ?? "";
|
|
options.ClientSecret = msAuth["ClientSecret"] ?? "";
|
|
options.CallbackPath = "/signin-microsoft";
|
|
options.BackchannelTimeout = TimeSpan.FromSeconds(30);
|
|
options.AuthorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
options.TokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
|
|
options.Events = new OAuthEvents
|
|
{
|
|
OnRedirectToAuthorizationEndpoint = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
|
|
logger.LogWarning($"=== MICROSOFT AUTH DEBUG ===");
|
|
logger.LogWarning($"Original RedirectUri: {context.RedirectUri}");
|
|
logger.LogWarning($"Request Scheme: {context.Request.Scheme}");
|
|
logger.LogWarning($"Request IsHttps: {context.Request.IsHttps}");
|
|
logger.LogWarning($"Request Host: {context.Request.Host}");
|
|
logger.LogWarning($"X-Forwarded-Proto: {context.Request.Headers["X-Forwarded-Proto"]}");
|
|
|
|
var originalUri = context.RedirectUri;
|
|
|
|
// Fix para Cloudflare - remover porta 443 explícita (mesmo fix do Google)
|
|
if (!builder.Environment.IsDevelopment())
|
|
{
|
|
context.RedirectUri = originalUri.Replace(":443", "");
|
|
logger.LogWarning($"REMOVED :443 - Modified RedirectUri: {context.RedirectUri}");
|
|
}
|
|
|
|
if (originalUri.Contains("bcards.site"))
|
|
{
|
|
context.RedirectUri = context.RedirectUri
|
|
.Replace("http://bcards.site", "https://bcards.site")
|
|
.Replace("http%3A%2F%2Fbcards.site", "https%3A%2F%2Fbcards.site");
|
|
|
|
logger.LogWarning($"FORCED HTTPS - Modified RedirectUri: {context.RedirectUri}");
|
|
}
|
|
|
|
var redirectUri = context.RedirectUri;
|
|
if (!redirectUri.Contains("prompt="))
|
|
{
|
|
redirectUri += "&prompt=login";
|
|
}
|
|
|
|
if (!redirectUri.Contains("options="))
|
|
{
|
|
redirectUri += "&options=disable_passkey";
|
|
logger.LogWarning($"ADDED options=disable_passkey - Modified RedirectUri: {redirectUri}");
|
|
}
|
|
|
|
logger.LogWarning($"Final RedirectUri: {redirectUri}");
|
|
context.Response.Redirect(redirectUri);
|
|
return Task.CompletedTask;
|
|
},
|
|
OnRemoteFailure = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
logger.LogWarning("Microsoft remote failure: {Failure}", context.Failure?.Message);
|
|
|
|
context.Response.Redirect("/Auth/Login?error=remote_failure");
|
|
context.HandleResponse();
|
|
return Task.CompletedTask;
|
|
}
|
|
};
|
|
});
|
|
|
|
#if TESTING
|
|
// Conditionally set the DefaultChallengeScheme and register the Test scheme
|
|
if (builder.Environment.IsEnvironment("Testing"))
|
|
{
|
|
authBuilder.Services.Configure<AuthenticationOptions>(options =>
|
|
{
|
|
options.DefaultChallengeScheme = TestAuthConstants.AuthenticationScheme;
|
|
});
|
|
authBuilder.AddScheme<TestAuthSchemeOptions, TestAuthHandler>(TestAuthConstants.AuthenticationScheme, _ => { });
|
|
}
|
|
else
|
|
{
|
|
authBuilder.Services.Configure<AuthenticationOptions>(options =>
|
|
{
|
|
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
|
|
});
|
|
}
|
|
#else
|
|
authBuilder.Services.Configure<AuthenticationOptions>(options =>
|
|
{
|
|
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
|
|
});
|
|
#endif
|
|
|
|
// Localization
|
|
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
|
|
|
|
builder.Services.Configure<RequestLocalizationOptions>(options =>
|
|
{
|
|
var supportedCultures = new[]
|
|
{
|
|
new CultureInfo("pt-BR"),
|
|
new CultureInfo("es-ES")
|
|
};
|
|
|
|
options.DefaultRequestCulture = new RequestCulture("pt-BR");
|
|
options.SupportedCultures = supportedCultures;
|
|
options.SupportedUICultures = supportedCultures;
|
|
});
|
|
|
|
// Register Services
|
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
builder.Services.AddScoped<IUserPageRepository, UserPageRepository>();
|
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
|
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
|
builder.Services.AddSingleton<IModerationAuthService, ModerationAuthService>();
|
|
|
|
builder.Services.AddScoped<IUserPageService, UserPageService>();
|
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
|
builder.Services.AddScoped<ISeoService, SeoService>();
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddSingleton<IPlanConfigurationService, PlanConfigurationService>();
|
|
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
|
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
|
builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
|
builder.Services.AddScoped<IModerationService, ModerationService>();
|
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
|
builder.Services.AddScoped<IDowngradeService, DowngradeService>();
|
|
|
|
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
|
|
|
|
// Configure upload limits for file handling (images up to 5MB)
|
|
builder.Services.Configure<FormOptions>(options =>
|
|
{
|
|
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB
|
|
options.ValueLengthLimit = int.MaxValue;
|
|
options.ValueCountLimit = int.MaxValue;
|
|
options.KeyLengthLimit = int.MaxValue;
|
|
options.BufferBody = true;
|
|
options.BufferBodyLengthLimit = 5 * 1024 * 1024; // 5MB
|
|
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB
|
|
options.MultipartHeadersLengthLimit = 16384;
|
|
});
|
|
|
|
// Configure Kestrel server limits for larger requests
|
|
builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
|
|
{
|
|
options.Limits.MaxRequestBodySize = 5 * 1024 * 1024; // 5MB
|
|
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2);
|
|
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
|
});
|
|
|
|
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
|
|
builder.Services.AddScoped<ILivePageService, LivePageService>();
|
|
|
|
builder.Services.AddHttpClient<OpenGraphService>();
|
|
|
|
builder.Services.AddSingleton<ISendGridClient>(provider =>
|
|
{
|
|
var apiKey = builder.Configuration["SendGrid:ApiKey"];
|
|
return new SendGridClient(apiKey);
|
|
});
|
|
|
|
builder.Services.AddHostedService<TrialExpirationService>();
|
|
|
|
builder.Services.AddResponseCaching();
|
|
builder.Services.AddMemoryCache();
|
|
builder.Services.AddRazorPages();
|
|
|
|
builder.Services.AddScoped<IOAuthHealthService, OAuthHealthService>();
|
|
|
|
builder.Services.AddHttpClient<OAuthHealthService>(client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(5);
|
|
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
|
});
|
|
|
|
builder.Services.AddHealthChecks()
|
|
.AddCheck<CriticalServicesHealthCheck>(
|
|
name: "critical_services",
|
|
failureStatus: HealthStatus.Unhealthy,
|
|
timeout: TimeSpan.FromSeconds(30))
|
|
.AddCheck<OAuthProvidersHealthCheck>(
|
|
name: "oauth_providers",
|
|
failureStatus: HealthStatus.Degraded,
|
|
timeout: TimeSpan.FromSeconds(10))
|
|
.AddCheck<SendGridHealthCheck>(
|
|
name: "sendgrid",
|
|
failureStatus: HealthStatus.Degraded,
|
|
timeout: TimeSpan.FromSeconds(10))
|
|
.AddCheck<SystemResourcesHealthCheck>(
|
|
name: "resources",
|
|
failureStatus: HealthStatus.Degraded,
|
|
timeout: TimeSpan.FromSeconds(5));
|
|
|
|
builder.Services.AddTransient<CriticalServicesHealthCheck>();
|
|
builder.Services.AddTransient<OAuthProvidersHealthCheck>();
|
|
builder.Services.AddTransient<SendGridHealthCheck>();
|
|
builder.Services.AddTransient<SystemResourcesHealthCheck>();
|
|
|
|
builder.Services.AddHttpClient<CriticalServicesHealthCheck>(client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(15);
|
|
client.DefaultRequestHeaders.Add("User-Agent", "BCards-CriticalCheck/1.0");
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseForwardedHeaders();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.Use(async (context, next) =>
|
|
{
|
|
context.Request.Scheme = "https";
|
|
|
|
if (context.Request.Host.Host == "bcards.site")
|
|
{
|
|
// Fix para Cloudflare - não especificar porta explícita
|
|
context.Request.Host = new HostString("bcards.site");
|
|
}
|
|
|
|
await next();
|
|
});
|
|
}
|
|
|
|
app.Use(async (context, next) =>
|
|
{
|
|
// Security headers
|
|
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
|
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
|
context.Response.Headers.Append("Referrer-Policy", "no-referrer");
|
|
context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
|
|
// Load balancer e debugging headers
|
|
context.Response.Headers.Append("X-Server-ID", Environment.MachineName);
|
|
context.Response.Headers.Append("X-Instance-ID", $"{Environment.MachineName}-{Environment.ProcessId}");
|
|
|
|
// Cloudflare information headers (quando disponível)
|
|
var cfCountry = context.Request.Headers["CF-IPCountry"].FirstOrDefault();
|
|
var cfRay = context.Request.Headers["CF-RAY"].FirstOrDefault();
|
|
|
|
if (!string.IsNullOrEmpty(cfCountry))
|
|
context.Response.Headers.Append("X-Country", cfCountry);
|
|
if (!string.IsNullOrEmpty(cfRay))
|
|
context.Response.Headers.Append("X-CF-Ray", cfRay);
|
|
|
|
context.Response.OnStarting(() =>
|
|
{
|
|
context.Response.Headers.Remove("Server");
|
|
context.Response.Headers.Remove("X-Powered-By");
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
await next();
|
|
});
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Home/Error");
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.UseHttpsRedirection();
|
|
app.UseResponseCompression();
|
|
|
|
app.UseStaticFiles(new StaticFileOptions
|
|
{
|
|
OnPrepareResponse = ctx =>
|
|
{
|
|
var fileName = ctx.File.Name;
|
|
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
|
|
|
TimeSpan maxAge;
|
|
string cacheControl;
|
|
|
|
if (extension == ".css" || extension == ".js")
|
|
{
|
|
maxAge = TimeSpan.FromDays(30);
|
|
cacheControl = $"public, max-age={maxAge.TotalSeconds}, immutable";
|
|
}
|
|
else if (extension == ".woff" || extension == ".woff2" || extension == ".ttf" ||
|
|
extension == ".eot" || extension == ".svg" || extension == ".otf")
|
|
{
|
|
maxAge = TimeSpan.FromDays(365);
|
|
cacheControl = $"public, max-age={maxAge.TotalSeconds}, immutable";
|
|
}
|
|
else if (extension == ".png" || extension == ".jpg" || extension == ".jpeg" ||
|
|
extension == ".gif" || extension == ".ico" || extension == ".webp")
|
|
{
|
|
maxAge = TimeSpan.FromDays(180);
|
|
cacheControl = $"public, max-age={maxAge.TotalSeconds}";
|
|
}
|
|
else
|
|
{
|
|
maxAge = TimeSpan.FromDays(1);
|
|
cacheControl = $"public, max-age={maxAge.TotalSeconds}";
|
|
}
|
|
|
|
ctx.Context.Response.Headers.CacheControl = cacheControl;
|
|
ctx.Context.Response.Headers.Append("Vary", "Accept-Encoding");
|
|
}
|
|
});
|
|
|
|
app.UseRouting();
|
|
app.UseRequestLocalization();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
// Cache middleware para páginas dinâmicas
|
|
app.Use(async (context, next) =>
|
|
{
|
|
// Páginas user dinâmicas - sem cache
|
|
if (context.Request.Path.StartsWithSegments("/page/") ||
|
|
context.Request.Path.StartsWithSegments("/Admin") ||
|
|
context.Request.Path.StartsWithSegments("/Auth") ||
|
|
context.Request.Path.StartsWithSegments("/Payment"))
|
|
{
|
|
context.Response.Headers.Append("Cache-Control", "private, no-store, must-revalidate");
|
|
context.Response.Headers.Append("Pragma", "no-cache");
|
|
context.Response.Headers.Append("Expires", "0");
|
|
}
|
|
// API endpoints - sem cache
|
|
else if (context.Request.Path.StartsWithSegments("/api/") ||
|
|
context.Request.Path.StartsWithSegments("/click/") ||
|
|
context.Request.Path.StartsWithSegments("/health"))
|
|
{
|
|
context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
context.Response.Headers.Append("Pragma", "no-cache");
|
|
}
|
|
// Páginas públicas - cache moderado
|
|
else if (context.Request.Path.StartsWithSegments("/Home") || context.Request.Path.Value == "/")
|
|
{
|
|
context.Response.Headers.Append("Cache-Control", "public, max-age=300"); // 5 min
|
|
}
|
|
|
|
await next();
|
|
});
|
|
|
|
app.UseMiddleware<SmartCacheMiddleware>();
|
|
app.UseMiddleware<AuthCacheMiddleware>();
|
|
app.UseMiddleware<PlanLimitationMiddleware>();
|
|
app.UseMiddleware<PageStatusMiddleware>();
|
|
app.UseMiddleware<ModerationAuthMiddleware>();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.Use(async (context, next) =>
|
|
{
|
|
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
|
|
|
logger.LogDebug("Request - Path: {Path}, Query: {Query}, Method: {Method}, Scheme: {Scheme}, IsHttps: {IsHttps}, Host: {Host}",
|
|
context.Request.Path, context.Request.QueryString, context.Request.Method,
|
|
context.Request.Scheme, context.Request.IsHttps, context.Request.Host);
|
|
|
|
if (context.Request.Path.StartsWithSegments("/signin-microsoft"))
|
|
{
|
|
logger.LogWarning("SIGNIN-MICROSOFT CALLBACK - Path: {Path}, Query: {Query}, Scheme: {Scheme}, IsHttps: {IsHttps}, Host: {Host}, X-Forwarded-Proto: {ForwardedProto}",
|
|
context.Request.Path, context.Request.QueryString, context.Request.Scheme,
|
|
context.Request.IsHttps, context.Request.Host, context.Request.Headers["X-Forwarded-Proto"]);
|
|
}
|
|
|
|
await next();
|
|
});
|
|
}
|
|
|
|
app.UseResponseCaching();
|
|
|
|
app.MapControllerRoute(
|
|
name: "userpage-preview-path",
|
|
pattern: "page/preview/{category}/{slug}",
|
|
defaults: new { controller = "UserPage", action = "Preview" },
|
|
constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "userpage-click",
|
|
pattern: "page/click/{pageId}",
|
|
defaults: new { controller = "UserPage", action = "RecordClick" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "moderation",
|
|
pattern: "moderation/{action=Dashboard}/{id?}",
|
|
defaults: new { controller = "Moderation" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "livepage",
|
|
pattern: "page/{category}/{slug}",
|
|
defaults: new { controller = "LivePage", action = "Display" },
|
|
constraints: new
|
|
{
|
|
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$",
|
|
slug = @"^[a-z0-9-]+$"
|
|
});
|
|
|
|
app.MapControllerRoute(
|
|
name: "privacy-pt",
|
|
pattern: "privacidade",
|
|
defaults: new { controller = "Legal", action = "Privacy" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "terms-pt",
|
|
pattern: "termos",
|
|
defaults: new { controller = "Legal", action = "Terms" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "guidelines-pt",
|
|
pattern: "regras",
|
|
defaults: new { controller = "Legal", action = "CommunityGuidelines" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "privacy-es",
|
|
pattern: "privacy",
|
|
defaults: new { controller = "Legal", action = "PrivacyES" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "terms-es",
|
|
pattern: "terminos",
|
|
defaults: new { controller = "Legal", action = "TermsES" });
|
|
|
|
app.MapControllerRoute(
|
|
name: "default",
|
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
|
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var themeService = scope.ServiceProvider.GetRequiredService<IThemeService>();
|
|
var categoryService = scope.ServiceProvider.GetRequiredService<ICategoryService>();
|
|
|
|
try
|
|
{
|
|
var existingThemes = await themeService.GetAvailableThemesAsync();
|
|
if (!existingThemes.Any())
|
|
{
|
|
await themeService.InitializeDefaultThemesAsync();
|
|
}
|
|
|
|
var existingCategories = await categoryService.GetAllCategoriesAsync();
|
|
if (!existingCategories.Any())
|
|
{
|
|
await categoryService.InitializeDefaultCategoriesAsync();
|
|
}
|
|
|
|
Log.Information("Default themes and categories initialized successfully");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Error initializing default data");
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
Log.Information("BCards application started successfully on {Hostname}", hostname);
|
|
|
|
if (isDevelopment)
|
|
{
|
|
Console.WriteLine("[DEBUG] Aguardando envio de logs iniciais...");
|
|
await Task.Delay(2000);
|
|
Console.WriteLine("[DEBUG] Iniciando aplicação...");
|
|
}
|
|
|
|
app.Run();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "BCards application terminated unexpectedly on {Hostname}", hostname);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
Log.Information("BCards application shutting down on {Hostname}", hostname);
|
|
|
|
await Task.Delay(5000);
|
|
Log.CloseAndFlush();
|
|
}
|
|
|
|
public partial class Program { }
|